Files
makelore/tests/e2e/teacher-reply-layout.spec.ts
2026-09-29 23:04:06 +08:00

271 lines
18 KiB
TypeScript

import { test, expect } from '@playwright/test';
import { createServer, type ViteDevServer } from 'vite';
import { realpathSync } from 'node:fs';
import { resolve } from 'node:path';
let server: ViteDevServer;
let url: string;
test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
test.beforeAll(async () => {
server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-teacher-reply', resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, server: { host: '127.0.0.1', port: 0, fs: { allow: [resolve('.'), realpathSync(resolve('node_modules'))] } }, plugins: [{
name: 'isolated-reply-test', enforce: 'pre',
load(id) {
if (id.endsWith('/src/lib/coding-teacher.ts')) return "export { teacherApi, teacherTopicsPath, teacherConversationsPath, legacyTopicBase } from '/tests/e2e/fixtures/teacher-reply-api.ts';";
if (id.endsWith('/src/stores/auth.ts')) return 'export const useAuthStore = selector => selector({user:{userId:"fixture"}});';
},
configureServer(vite) { vite.middlewares.use((req, res, next) => {
if (!req.url?.startsWith('/?') && req.url !== '/') return next();
res.setHeader('Content-Type', 'text/html');
res.end('<html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/tests/e2e/fixtures/teacher-reply-layout.tsx"></script></body></html>');
}); },
}] });
await server.listen(); url = server.resolvedUrls!.local[0];
});
test.afterAll(async () => { await server?.close(); });
test('retry stickers stay readable and preserve drafts in a 319px consultation', async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
await page.addInitScript(() => localStorage.clear());
for (const status of ['failed', 'cancelled']) {
await page.goto(`${url}?kind=retry&status=${status}&width=319`);
const panel = page.getByTestId('teacher-chat-panel');
const retry = panel.locator('.consultation-retry');
await expect(retry).toBeEnabled();
await expect(retry).toHaveText('帮我整体看🧠');
expect(await retry.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const [panelBounds, retryBounds] = await Promise.all([panel.boundingBox(), retry.boundingBox()]);
expect(retryBounds!.x).toBeGreaterThanOrEqual(panelBounds!.x);
expect(retryBounds!.x + retryBounds!.width).toBeLessThanOrEqual(panelBounds!.x + panelBounds!.width);
await panel.screenshot({ path: test.info().outputPath(`retry-${status}-319.png`) });
const input = page.getByRole('textbox', { name: '向智能体提问' });
await input.fill('我还有一点自己的想法');
await retry.click();
await expect(page.getByText('我们可以先从一个小地方开始。')).toBeVisible();
await expect(input).toHaveValue('我还有一点自己的想法');
expect(await page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs)).toEqual([
{ requestId: expect.any(String), text: '帮我整体看🧠', intent: 'suggestions', references: [], projectId: 'fixture', sourceConversationId: 'source' },
]);
}
});
for (const width of [319, 508]) {
test(`published shortcuts send their prompts and keep eight long labels scrollable at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
await page.goto(`${url}?kind=shortcuts&width=${width}`);
const panel = page.getByTestId('teacher-chat-panel');
const shortcuts = page.locator('form .consultation-hint');
await expect(shortcuts).toHaveCount(8);
await expect(shortcuts.first()).toHaveText('看看进展');
await expect(shortcuts.nth(1)).toHaveText('解释代码');
await expect(shortcuts.nth(1)).toBeEnabled();
const row = panel.getByRole('group', { name: '快捷提问' });
const composer = panel.locator('.consultation-composer');
await expect(composer.locator('.consultation-hint')).toHaveCount(0);
await expect(shortcuts.first()).toHaveCSS('font-size', await panel.getByTestId('teacher-reply').first().evaluate(el => getComputedStyle(el).fontSize));
await expect(shortcuts.first()).toHaveCSS('font-weight', '400');
await expect(shortcuts.first()).toHaveCSS('border-top-width', '0px');
await expect(shortcuts.first()).not.toHaveCSS('box-shadow', 'none');
expect((await row.boundingBox())!.y + (await row.boundingBox())!.height).toBeLessThan((await composer.boundingBox())!.y);
await shortcuts.first().focus();
await expect(shortcuts.first()).toHaveCSS('outline-style', 'solid');
await expect(shortcuts.first()).toHaveCSS('cursor', 'pointer');
await shortcuts.first().hover();
await expect(shortcuts.first()).toHaveCSS('transform', 'matrix(1, 0, 0, 1, 0, -1)');
await page.mouse.down();
await expect(shortcuts.first()).toHaveCSS('transform', 'matrix(1, 0, 0, 1, 0, 1)');
await page.mouse.move(0, 0);
await page.mouse.up();
await page.emulateMedia({ reducedMotion: 'reduce' });
await shortcuts.first().hover();
await expect(shortcuts.first()).toHaveCSS('transform', 'none');
await page.mouse.move(0, 0);
await page.emulateMedia({ reducedMotion: 'no-preference' });
const form = (await panel.locator('form').boundingBox())!;
expect(form.y + form.height).toBeLessThanOrEqual(800);
expect(await panel.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await shortcuts.last().scrollIntoViewIfNeeded();
await panel.screenshot({ path: test.info().outputPath(`shortcuts-${width}.png`) });
await shortcuts.last().click();
await expect(page.getByText('请讨论第 8 个主题。', { exact: true })).toBeVisible();
await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible();
await shortcuts.nth(1).click();
await expect(page.getByText('请解释当前项目最近的代码改动,用一个具体例子帮助我理解。', { exact: true })).toBeVisible();
const inputs = await page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs);
expect(inputs).toEqual([
{ requestId: expect.any(String), text: '请讨论第 8 个主题。', references: [], projectId: 'fixture', sourceConversationId: 'source' },
{ requestId: expect.any(String), text: '请解释当前项目最近的代码改动,用一个具体例子帮助我理解。', references: [], projectId: 'fixture', sourceConversationId: 'source' },
]);
await page.goto(`${url}?kind=shortcuts-empty&width=${width}`);
await expect(page.getByRole('textbox', { name: '向智能体提问' })).toBeEnabled();
await expect(page.locator('form .consultation-hint')).toHaveCount(0);
});
for (const chat of ['legacy', 'continuous']) test(`quoted ${chat} replies recover fully and incomplete replies retry without losing the draft at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
const sent = () => page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs);
await page.goto(`${url}?kind=recovered&width=${width}&chat=${chat}`);
const panel = page.getByTestId('teacher-chat-panel');
const body = page.getByTestId('teacher-reply');
await expect(body).toContainText('它早就不是"刚搭好架子"那种阶段了');
await expect(body).toContainText('现在的阶段是「能玩,正在调手感」。');
await expect(page.getByText('查看收到的原始内容').locator('..')).not.toHaveAttribute('open');
await expect(page.getByRole('button', { name: '重新回答' })).toHaveCount(0);
expect(await sent()).toEqual([]);
expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await panel.screenshot({ path: test.info().outputPath(`recovered-${width}.png`) });
await page.goto(`${url}?kind=incomplete&width=${width}&chat=${chat}`);
await expect(page.getByRole('status')).toHaveText('这次回复未能完整显示');
await expect(page.getByTestId('teacher-reply')).toHaveCount(0);
expect(await sent()).toEqual([]);
const input = page.getByRole('textbox', { name: '向智能体提问' });
await input.fill('我还在写的新想法');
const retry = page.getByRole('button', { name: '重新回答' });
const bounds = (await retry.boundingBox())!;
expect(bounds.height).toBeGreaterThanOrEqual(44);
expect(await panel.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await panel.screenshot({ path: test.info().outputPath(`incomplete-${width}.png`) });
await retry.click();
await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible();
await expect(input).toHaveValue('我还在写的新想法');
expect(await sent()).toEqual([{
requestId: expect.any(String), text: '现在项目是什么阶段?', ...(chat === 'continuous' ? { projectId: 'fixture' } : {}),
sourceConversationId: 'original-source', references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }],
}]);
});
test(`empty-composer hint stays readable and keeps the send position stable at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
await page.goto(`${url}?kind=ordinary&width=${width}`);
const input = page.getByRole('textbox', { name: '向智能体提问' });
const hint = page.locator('form .consultation-hint');
const send = page.getByRole('button', { name: '提问', exact: true });
const sent = () => page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs);
await expect(hint).toBeEnabled();
await expect(hint).toHaveText('帮我整体看🧠');
expect(await hint.evaluate(el => el.scrollWidth <= el.clientWidth && el.scrollHeight <= el.clientHeight)).toBe(true);
await expect(hint).toHaveCSS('background-color', 'rgb(248, 230, 183)');
const initialSend = (await send.boundingBox())!;
const hintBounds = (await hint.boundingBox())!;
expect(hintBounds.x).toBeLessThan(initialSend.x);
expect(hintBounds.height).toBe(30);
expect(hintBounds.y + hintBounds.height).toBeLessThan((await page.locator('.consultation-composer').boundingBox())!.y);
await page.getByTestId('teacher-chat-panel').screenshot({ path: test.info().outputPath(`composer-hint-${width}.png`) });
expect(await sent()).toEqual([]);
await input.fill('我想先自己说说');
await expect(hint).toHaveCount(0);
expect(await send.boundingBox()).toEqual(initialSend);
await input.fill(' ');
await expect(hint).toBeVisible();
await input.fill('');
await expect(hint).toBeVisible();
expect(await send.boundingBox()).toEqual(initialSend);
expect(await sent()).toEqual([]);
await hint.click();
await expect(page.getByText('我们可以先从一个小地方开始。')).toBeVisible();
expect(await sent()).toEqual([{ requestId: expect.any(String), text: '帮我整体看🧠', intent: 'suggestions', references: [], projectId: 'fixture', sourceConversationId: 'source' }]);
await expect(page.getByRole('button', { name: '我也说不清,你带我看看', exact: true })).toHaveCount(0);
await expect(page.locator('.consultation-messages').getByRole('button', { name: '帮我整体看🧠', exact: true })).toHaveCount(0);
const card = page.getByRole('button', { name: '可以从哪个小地方开始?' });
await expect(card).toBeVisible();
await input.fill('留着我自己的草稿');
await expect(hint).toHaveCount(0);
await card.click();
await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible();
await expect(input).toHaveValue('留着我自己的草稿');
expect(await sent()).toHaveLength(2);
await page.screenshot({ path: test.info().outputPath(`composer-draft-${width}.png`) });
});
test(`rich replies keep code, tables and images inside the panel at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('https://example.com/teacher-preview.png', route => route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="900" height="250"><rect width="900" height="250" fill="#ebeedf"/><text x="30" y="130" font-size="32">Flybot project preview</text></svg>',
}));
await page.goto(`${url}?kind=rich-text&width=${width}`);
const body = page.getByTestId('teacher-reply');
await expect(body.getByRole('heading', { name: '项目建议' })).toHaveCount(1);
await expect(body.getByRole('table')).toHaveCount(1);
await expect(body.locator('.katex')).toHaveCount(1);
await expect(body.getByRole('img', { name: '项目截图' })).toBeVisible();
await expect.poll(() => body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await expect.poll(() => page.locator('.consultation-messages').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const raw = page.getByText('查看收到的原始内容');
await expect(raw.locator('..')).not.toHaveAttribute('open');
await raw.click();
await expect(raw.locator('..').locator('pre')).toContainText('"tool"');
const input = page.getByRole('textbox', { name: '向智能体提问' });
await expect(input).toBeVisible();
await page.locator('.consultation-messages').evaluate(el => { el.scrollTop = 0; });
await page.screenshot({ path: test.info().outputPath(`rich-reply-${width}.png`) });
expect(errors).toEqual([]);
});
}
for (const width of [319, 508]) for (const kind of ['ordinary', 'ideas', 'structure', 'flow', 'comparison']) {
test(`${kind} history keeps ordinary replies, quick cards and the composer usable at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
await page.addInitScript(() => localStorage.setItem(
'makelore-consultation-draft:fixture:fixture:friend', JSON.stringify({ text: '', references: [] }),
));
await page.goto(`${url}?kind=${kind}&width=${width}`);
const input = page.getByRole('textbox', { name: '向智能体提问' });
await expect(input).toBeEnabled();
await expect(page.getByRole('complementary', { name: '麦洛智能体咨询' })).toBeVisible();
await expect(page.getByTestId('teacher-chat-panel').locator('header')).toHaveCount(0);
await expect(page.getByRole('button', { name: '智能体新话题' })).toHaveCount(0);
await expect(page.getByRole('combobox')).toHaveCount(0);
await expect(page.locator('form')).not.toHaveText(/^0/);
await expect(page.getByText('查看旧版朋友草稿')).toHaveCount(0);
await expect(page.getByTestId('teacher-discussion')).toHaveCount(0);
await expect(page.getByRole('button', { name: /用这个一起想|先这些|接着改|把想法理一理|聊聊这一点/ })).toHaveCount(0);
await input.fill('保留我的草稿');
const card = page.getByRole('button', { name: '有什么好玩的想法?' });
await expect(card).toBeVisible();
await expect(card).toHaveCSS('font-size', '14px');
await expect(card).toHaveCSS('padding', '10px 12px');
await expect(card.locator('svg')).toHaveCSS('width', '14px');
expect((await card.boundingBox())!.height).toBeGreaterThanOrEqual(44);
expect(await card.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await card.click();
await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible();
await expect(input).toHaveValue('保留我的草稿');
await input.fill('它还喜欢和我踢球');
await page.getByRole('button', { name: '提问', exact: true }).click();
await expect(page.getByText('它还喜欢和我踢球', { exact: true })).toBeVisible();
await expect(page.getByText('好,我们就从这一个问题聊起。')).toHaveCount(2);
await expect(input).toHaveValue('');
await expect(page.getByTestId('teacher-discussion')).toHaveCount(0);
expect(await page.locator('.consultation-messages').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const composer = await input.boundingBox();
expect(composer!.y + composer!.height).toBeLessThan(800);
await page.screenshot({ path: test.info().outputPath(`${kind}-${width}.png`) });
expect(errors).toEqual([]);
});
}
for (const width of [319, 508]) test(`teacher composer shows only microphone and send controls at ${width}px`, async ({ page }) => {
const savedModel = width === 508 ? 'test-second-model' : undefined;
await page.addInitScript((model) => {
localStorage.clear();
if (model) localStorage.setItem('makelore-teacher-model:fixture:coding-teacher:published', model);
}, savedModel);
await page.goto(`${url}?kind=rich-text&width=${width}`);
const panel = page.getByTestId('teacher-chat-panel');
const input = page.getByRole('textbox', { name: '向智能体提问' });
await input.fill('保留麦克风和发送按钮');
await expect(panel.getByRole('button', { name: /老师模型/ })).toHaveCount(0);
await expect(panel.getByRole('button', { name: '语音输入' })).toBeVisible();
await expect(panel.getByRole('button', { name: '提问', exact: true })).toBeEnabled();
expect(await panel.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const controls = panel.getByTestId('teacher-composer-runtime-controls');
await expect(controls.getByRole('button')).toHaveCount(2);
await expect(controls).not.toContainText(/老师配置|模型默认|fixture|test-second-model/);
expect(await controls.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await panel.screenshot({ path: test.info().outputPath(`teacher-composer-${width}.png`) });
await panel.getByRole('button', { name: '提问', exact: true }).click();
expect(await page.evaluate(() => (window as unknown as { __teacherReplyInputs: Array<{text: string; modelId?: string}> }).__teacherReplyInputs.at(-1))).toMatchObject({ text: '保留麦克风和发送按钮' });
expect(await page.evaluate(() => (window as unknown as { __teacherReplyInputs: Array<{modelId?: string}> }).__teacherReplyInputs.at(-1)?.modelId)).toBe(savedModel);
});