import { test, expect } 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-community-layout', resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, server: { host: '127.0.0.1', port: 0 }, plugins: [{ name: 'isolated-community', 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 ` export async function createHostEventSource() { return { close() {}, addEventListener() {} }; } export async function ensureHostApiToken() { return 'fixture'; } export function getHostApiBase() { return ''; } export function reportDesktopActivity() {} export function setDesktopBackgroundLease() {} export async function hostApiFetch() { return {}; } `; }, configureServer(vite) { vite.middlewares.use((req, res, next) => { if (req.url !== '/' && !req.url?.startsWith('/?')) return next(); res.setHeader('Content-Type', 'text/html'); res.end('
'); }); }, }], }); await server.listen(); baseUrl = server.resolvedUrls!.local[0]; }); test.afterAll(async () => { await server?.close(); }); test.beforeEach(async ({ page }) => { page.on('pageerror', error => { throw error; }); await page.addInitScript(() => { const scope = window as typeof window & { __communityCalls: unknown[][] }; scope.__communityCalls = []; Object.defineProperty(window, 'electron', { value: { ipcRenderer: { invoke: async (...args: unknown[]) => { scope.__communityCalls.push(args); return undefined; }, } } }); }); }); for (const width of [375, 768, 1280, 1470]) { test(`welcome and bottom controls remain usable with floating teachers at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 850 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(baseUrl + '?teachers'); const heading = page.getByTestId('coding-start-heading'); const teachers = page.getByTestId('teacher-welcomes'); await expect(teachers.locator('.teacher-welcome-card')).toHaveCount(2); if (width < 900) { await expect.poll(async () => { const a = await heading.boundingBox(), b = await teachers.boundingBox(); return !!a && !!b && a.y >= b.y + b.height; }).toBe(true); } await expect(page.locator('.coding-start-mark')).toHaveCSS('animation-name', 'none'); await page.getByTestId('community-invitation').scrollIntoViewIfNeeded(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); await expect(page.getByRole('button', { name: '去作品广场逛逛' })).toBeVisible(); while (await teachers.count()) await teachers.getByRole('button', { name: /^收起全部老师介绍/ }).first().click(); await heading.scrollIntoViewIfNeeded(); const composer = page.getByTestId('coding-message-composer'); await expect(composer).toBeInViewport({ ratio: 1 }); const box = (await composer.boundingBox())!; expect(850 - box.y - box.height).toBeLessThanOrEqual(40); const invitation = (await page.getByTestId('community-invitation').boundingBox())!; expect(box.y - invitation.y - invitation.height).toBeCloseTo(12, 0); await page.screenshot({ path: test.info().outputPath(`community-welcome-${width}.png`) }); }); } test('a short window scrolls the welcome while keeping the composer at the bottom', async ({ page }) => { await page.setViewportSize({ width: 375, height: 600 }); await page.goto(baseUrl + '?teachers'); const composer = page.getByTestId('coding-message-composer'); await expect(composer).toBeInViewport({ ratio: 1 }); const before = (await composer.boundingBox())!; await page.locator('.classroom-chat-view').evaluate(el => { el.scrollTop = el.scrollHeight; }); const after = (await composer.boundingBox())!; expect(after.y).toBeCloseTo(before.y, 0); expect(600 - after.y - after.height).toBeLessThanOrEqual(40); }); for (const width of [1280, 1470]) { test(`floating teachers leave the welcome and bottom input centered in the page at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 850 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(baseUrl + '?teachers&sidebar'); const teachers = page.getByTestId('teacher-welcomes'); const composer = page.getByTestId('coding-message-composer'); await expect(teachers).toHaveCSS('width', '340px'); await expect.poll(async () => { const [side, input] = await Promise.all([ page.getByTestId('project-sidebar').boundingBox(), composer.boundingBox(), ]); return Math.abs(input!.x + input!.width / 2 - (side!.x + side!.width + width) / 2); }).toBeLessThan(2); const input = (await composer.boundingBox())!; const invitation = (await page.getByTestId('community-invitation').boundingBox())!; expect(invitation.x).toBeCloseTo(input.x, 0); expect(invitation.width).toBeCloseTo(input.width, 0); expect(input.y - invitation.y - invitation.height).toBeCloseTo(12, 0); const heading = page.getByTestId('coding-start-heading'); await expect.poll(async () => { const [title, content] = await Promise.all([heading.boundingBox(), page.locator('.classroom-chat-view').boundingBox()]); return Math.abs(title!.y + title!.height / 2 - content!.y - content!.height / 2); }).toBeLessThan(2); expect((await heading.boundingBox())!.x + (await heading.boundingBox())!.width / 2).toBeCloseTo(input.x + input.width / 2, 0); await page.screenshot({ path: test.info().outputPath(`floating-teachers-centered-welcome-${width}.png`) }); await composer.getByRole('textbox').fill('保留这份草稿'); await page.getByTestId('project-sidebar').evaluate(el => { el.style.width = '60px'; }); await expect.poll(async () => { const [side, input] = await Promise.all([ page.getByTestId('project-sidebar').boundingBox(), composer.boundingBox(), ]); return Math.abs(input!.x + input!.width / 2 - (side!.x + side!.width + width) / 2); }).toBeLessThan(2); const inputBeforeDismiss = await composer.boundingBox(); const headingBeforeDismiss = await heading.boundingBox(); while (await teachers.count()) await teachers.getByRole('button', { name: /^收起全部老师介绍/ }).first().click(); await expect(composer.getByRole('textbox')).toHaveValue('保留这份草稿'); expect(await composer.boundingBox()).toEqual(inputBeforeDismiss); expect(await heading.boundingBox()).toEqual(headingBeforeDismiss); await expect.poll(async () => { const [side, input] = await Promise.all([page.getByTestId('project-sidebar').boundingBox(), composer.boundingBox()]); return Math.abs(input!.x + input!.width / 2 - (side!.x + side!.width + width) / 2); }).toBeLessThan(2); }); } test('the community link opens only on click and leaves the draft mounted', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 850 }); const communityRequests: string[] = []; page.on('request', request => { if (request.url().includes('square.nianxx.cn')) communityRequests.push(request.url()); }); await page.goto(baseUrl); const composer = page.getByRole('textbox'); await composer.fill('我想做一个自己的小镇'); await composer.evaluate(node => node.setAttribute('data-mounted', 'same')); await page.getByRole('button', { name: '去作品广场逛逛' }).click(); expect(await page.evaluate(() => (window as typeof window & { __communityCalls: unknown[][] }).__communityCalls)) .toContainEqual(['shell:openExternal', 'https://square.nianxx.cn/#explore']); expect(communityRequests).toEqual([]); await expect(page.locator('iframe')).toHaveCount(0); await expect(composer).toHaveValue('我想做一个自己的小镇'); await expect(composer).toHaveAttribute('data-mounted', 'same'); await page.getByRole('button', { name: '发送', exact: true }).click(); await expect(page.getByTestId('community-invitation')).toHaveCount(0); await expect(composer).toHaveAttribute('data-mounted', 'same'); });