Files
makelore/tests/e2e/teacher-preview-layout.spec.ts

280 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { test, expect, type Page } from '@playwright/test';
import { createServer, type ViteDevServer } from 'vite';
import { resolve } from 'node:path';
let server: ViteDevServer;
let baseUrl: 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-preview-layout',
resolve: { alias: { '@': resolve('src') } },
esbuild: { jsx: 'automatic' },
server: { host: '127.0.0.1', port: 0 },
plugins: [{
name: 'isolated-preview-layout', enforce: 'pre',
load(id) {
if (id.endsWith('/src/lib/host-events.ts')) return 'export function subscribeHostEvent() { return () => {}; }';
if (id.endsWith('/src/lib/host-api.ts')) return `
const empty = new URLSearchParams(location.search).has('empty-work');
const browser = { browserId: 'fixture', projectId: 'layout-fixture', state: 'attached', generation: 1, url: 'http://127.0.0.1:4173/', visible: true, bounds: null, eventCursor: 0 };
const emptyBrowser = { ...browser, browserId: null, state: 'closed', url: '', visible: false };
window.workPreviewChecks = 0;
export async function hostApiFetch(path) {
if (path.includes('ensure-work')) {
window.workPreviewChecks += 1;
return empty ? { status: 'empty' } : { status: 'ready', browser };
}
return { success: true, browser: empty ? emptyBrowser : browser };
}
`;
},
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url !== '/' && !req.url?.startsWith('/?')) 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-preview-layout.tsx"></script></body></html>');
});
},
}],
});
await server.listen();
baseUrl = server.resolvedUrls!.local[0];
});
test.afterAll(async () => { await server?.close(); });
test('empty work shows a local hint without opening the teacher or polling', async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 720 });
await page.clock.install();
await page.goto(`${baseUrl}?quiet=1&empty-work=1`);
await expect(page.getByTestId('agent-browser-viewport').getByRole('status')).toHaveText('还没有作品,快和老师聊聊吧');
await expect(page.getByRole('button', { name: '回到操作对话' })).toBeVisible();
await expect(page.getByRole('button', { name: /再试一次|帮我检查并打开|重新打开作品/ })).toHaveCount(0);
await expect(page.getByText('正在打开你的作品…')).toHaveCount(0);
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.locator('#coding-consultation-dock')).toHaveCount(0);
await page.clock.runFor(60_000);
expect(await page.evaluate(() => (window as typeof window & { workPreviewChecks: number }).workPreviewChecks)).toBe(1);
await page.screenshot({ path: test.info().outputPath('empty-work-hint.png') });
});
for (const width of [375, 768, 1280]) {
test(`all teacher introductions fit at ${width}px and keep the native preview clear`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await page.goto(baseUrl + '?agents=3&welcomes');
const group = page.getByTestId('teacher-welcomes');
await expect(group.locator('.teacher-welcome-card')).toHaveCount(3);
await expect(group).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(group).toHaveCSS('box-shadow', 'none');
await expect(group.getByText('来认识一下')).toHaveCount(0);
await expect(group.getByRole('button', { name: '先自己试试' })).toHaveCount(0);
await expect(group).toBeInViewport({ ratio: 1 });
for (const card of await group.locator('.teacher-welcome-card').all()) {
await expect(card).toBeInViewport({ ratio: 1 });
expect(await card.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
}
await expect.poll(async () => {
const [bubble, viewport] = await Promise.all([group.boundingBox(), page.getByTestId('agent-browser-viewport').boundingBox()]);
return !!bubble && !!viewport && viewport.y >= bubble.y + bubble.height;
}).toBe(true);
await page.screenshot({ path: test.info().outputPath(`teacher-introductions-${width}.png`) });
await group.getByRole('button', { name: '收起全部老师介绍(智能体2)' }).click();
await expect(group).toHaveCount(0);
await expect(page.locator('#coding-consultation-dock')).toHaveCount(0);
await page.reload();
await expect(group.locator('.teacher-welcome-card')).toHaveCount(3);
await group.getByRole('button', { name: '和智能体3聊聊' }).click({ position: { x: 8, y: 8 } });
await expect(page.getByRole('button', { name: '与智能体3聊天' })).toHaveAttribute('aria-pressed', 'true');
await expect(group).toHaveCount(0);
await page.getByRole('button', { name: '关闭智能体栏' }).click();
await expect(group).toHaveCount(0);
});
}
test('many teacher introductions remain scrollable and honor reduced motion', async ({ page }) => {
await page.setViewportSize({ width: 520, height: 600 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(baseUrl + '?agents=8&welcomes');
const group = page.getByTestId('teacher-welcomes');
await expect(group).toBeInViewport({ ratio: 1 });
await expect(group.locator('.teacher-welcome-card')).toHaveCount(8);
await expect(group.locator('.teacher-welcome-card').first()).toHaveCSS('animation-name', 'none');
const last = group.getByRole('button', { name: '和智能体8聊聊' });
await last.scrollIntoViewIfNeeded();
await expect(last).toBeInViewport({ ratio: 1 });
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(520);
await last.focus();
await page.keyboard.press('Enter');
await expect(page.getByRole('button', { name: '与智能体8聊天' })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: '关闭智能体栏' }).click();
await expect(group).toHaveCount(0);
});
for (const width of [520, 1000]) {
test(`distributed agent tabs scroll inside the title bar at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await page.goto(baseUrl + '?agents=8');
const tabs = page.getByRole('navigation', { name: '已下发智能体' });
const refresh = page.getByRole('button', { name: '刷新智能体', exact: true });
await expect(tabs.getByRole('button')).toHaveCount(8);
await expect(refresh).toBeVisible();
await expect(tabs.getByRole('button', { name: '刷新智能体', exact: true })).toHaveCount(0);
await expect(page.getByRole('combobox', { name: '新话题使用的智能体' })).toHaveCount(0);
const last = tabs.getByRole('button', { name: '与智能体8聊天' });
await last.focus();
await last.press('Enter');
await expect(last).toHaveAttribute('aria-pressed', 'true');
const bounds = await page.evaluate(() => {
const strip = document.querySelector('.consultation-agent-tabs')!;
const refresh = document.querySelector('button[aria-label="刷新智能体"]')!;
const controls = document.querySelector('[data-testid="window-controls"]')!;
return { right: strip.getBoundingClientRect().right, controlsLeft: controls.getBoundingClientRect().left,
refreshLeft: refresh.getBoundingClientRect().left, refreshRight: refresh.getBoundingClientRect().right,
scroll: strip.scrollLeft, documentWidth: document.documentElement.scrollWidth };
});
expect(bounds.right).toBeLessThanOrEqual(bounds.refreshLeft);
expect(bounds.refreshRight).toBeLessThanOrEqual(bounds.controlsLeft);
expect(bounds.documentWidth).toBeLessThanOrEqual(width);
expect(bounds.scroll).toBeGreaterThan(0);
await refresh.click();
await expect(tabs.getByRole('button')).toHaveCount(9);
await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible();
await expect(last).toHaveAttribute('aria-pressed', 'true');
await expect(refresh).toBeInViewport();
await expect(refresh).toBeEnabled();
await page.screenshot({ path: test.info().outputPath('distributed-agents-titlebar.png') });
});
}
async function geometry(page: Page) {
return page.evaluate(() => {
const box = (selector: string) => document.querySelector(selector)!.getBoundingClientRect();
const bubble = box('[data-testid="teacher-presence"]');
const viewport = box('[data-testid="agent-browser-viewport"]');
const panel = box('[data-testid="agent-browser-panel"]');
const work = box('[data-testid="work-view"]');
return { gap: panel.top - work.top, clearance: Number.parseFloat((document.querySelector('[data-testid="agent-browser-panel"]') as HTMLElement).style.marginTop) || 0, nativeGap: viewport.top - bubble.bottom, sideBySide: bubble.left >= viewport.right || bubble.right <= viewport.left };
});
}
for (const width of [520, 1000]) {
test(`teacher introduction floats outside the scrolling strip and clears the work at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await page.goto(`${baseUrl}?agents=8&quiet=1`);
const first = page.getByRole('button', { name: '与智能体1聊天', exact: true });
const card = page.getByTestId('teacher-introduction');
await expect(first).not.toHaveAttribute('title');
await first.hover();
await expect(card).toBeVisible();
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体1');
await page.getByRole('button', { name: '与智能体2聊天', exact: true }).hover();
await expect(card).toHaveCount(1);
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体2');
await first.hover();
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体1');
await expect.poll(async () => page.evaluate(() => {
const card = document.querySelector('[data-testid="teacher-introduction"]')!.getBoundingClientRect();
const viewport = document.querySelector('[data-testid="agent-browser-viewport"]')!.getBoundingClientRect();
return card.left >= 0 && card.right <= innerWidth && card.bottom <= innerHeight
&& (card.left >= viewport.right || card.right <= viewport.left || viewport.top - card.bottom >= 8);
})).toBe(true);
await card.hover();
await expect(card).toBeVisible();
await page.screenshot({ path: test.info().outputPath('teacher-hover-introduction.png') });
const cardBounds = (await card.boundingBox())!;
await page.screenshot({ path: test.info().outputPath('teacher-introduction-card.png'), clip: {
x: Math.max(0, cardBounds.x - 12), y: cardBounds.y - 12,
width: cardBounds.width + 24, height: cardBounds.height + 30,
} });
await page.mouse.move(0, 780);
await expect(card).toHaveCount(0);
await expect(page.getByTestId('agent-browser-panel')).toHaveCSS('margin-top', '0px');
const last = page.getByRole('button', { name: '与智能体8聊天', exact: true });
await last.focus();
await expect(card).toBeVisible();
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体8');
await last.press('Escape');
await expect(card).toHaveCount(0);
await expect(last).toBeFocused();
await last.hover();
await expect(card).toBeVisible();
await last.click();
await expect(card).toHaveCount(0);
await expect(page.locator('#coding-consultation-dock')).toBeVisible();
const lastBounds = (await last.boundingBox())!;
await page.mouse.move(lastBounds.x + lastBounds.width / 2 + 2, lastBounds.y + lastBounds.height / 2);
await page.waitForTimeout(300);
await expect(card).toHaveCount(0);
await page.mouse.move(0, 780);
await last.hover();
await expect(card).toBeVisible();
await last.click();
await expect(page.locator('#coding-consultation-dock')).toHaveCount(0);
});
}
test('closing an introduction preserves the unread speech and its native preview clearance', async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 720 });
await page.goto(`${baseUrl}?agents=2`);
await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8);
const original = (await geometry(page)).clearance;
await page.getByRole('button', { name: '与智能体2聊天', exact: true }).hover();
await expect(page.getByTestId('teacher-introduction')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByTestId('teacher-introduction')).toHaveCount(0);
await expect(page.getByTestId('teacher-invitation')).toBeVisible();
await expect.poll(async () => (await geometry(page)).clearance).toBe(original);
});
test('distributed icons load over HTTP and broken images fall back in the titlebar', async ({ page }) => {
await page.route('https://yuxi.example.test/api/storage/public/images/agent-0.png', route => route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="16" cy="16" r="15" fill="blue"/></svg>',
}));
await page.route('https://yuxi.example.test/api/storage/public/images/agent-1.png', route => route.fulfill({ status: 404 }));
await page.goto(`${baseUrl}?agents=2&icons=1`);
const custom = page.getByRole('button', { name: '与智能体1聊天' }).locator('img');
const fallback = page.getByRole('button', { name: '与智能体2聊天' }).locator('img');
const speaker = page.getByRole('button', { name: '打开这条智能体消息' }).locator('img');
await expect(custom).toHaveAttribute('src', 'https://yuxi.example.test/api/storage/public/images/agent-0.png');
await expect(custom).toHaveCSS('image-rendering', 'auto');
await expect(speaker).toHaveAttribute('src', 'https://yuxi.example.test/api/storage/public/images/agent-0.png');
await expect(fallback).toHaveAttribute('src', /avatar-06\.png/);
for (const img of [custom, fallback, speaker]) {
await expect.poll(() => img.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0);
}
await page.screenshot({ path: test.info().outputPath('distributed-icons.png') });
});
for (const size of [{ width: 1440, height: 900 }, { width: 1180, height: 720 }, { width: 1000, height: 640 }]) {
test(`preview only clears overlapping speech at ${size.width}x${size.height}`, async ({ page }) => {
await page.setViewportSize(size);
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(baseUrl);
await expect(page.getByTestId('teacher-invitation')).toBeVisible();
await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8);
expect((await geometry(page)).gap).toBeLessThan(65);
await page.getByRole('button', { name: '智能体', exact: true }).click();
await expect.poll(async () => (await geometry(page)).sideBySide).toBe(true);
await expect.poll(async () => (await geometry(page)).gap).toBe(20);
// A narrow dock can overlap again; increasing its width must give the
// preview all its space back without hiding the unread message.
await page.getByRole('slider', { name: '智能体栏宽度' }).press('Home');
await expect.poll(async () => { const g = await geometry(page); return g.sideBySide || g.nativeGap >= 8; }).toBe(true);
await page.getByRole('slider', { name: '智能体栏宽度' }).press('End');
await expect.poll(async () => (await geometry(page)).gap).toBe(20);
await page.screenshot({ path: test.info().outputPath('work-with-teacher-no-gap.png') });
await page.getByRole('button', { name: '关闭智能体栏' }).click();
await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8);
await page.getByRole('button', { name: '切换消息长度' }).click();
await expect.poll(async () => (await geometry(page)).gap).toBe(20);
await page.getByRole('button', { name: '等会儿聊' }).click();
await expect(page.getByTestId('teacher-invitation')).toHaveCount(0);
await expect(page.getByTestId('agent-browser-panel')).toHaveCSS('margin-top', '0px');
expect(errors).toEqual([]);
});
}