import { chromium } from 'playwright'; import fs from 'node:fs/promises'; import assert from 'node:assert/strict'; const url = process.argv[2] ?? 'http://127.0.0.1:4173/'; const label = process.argv[3] ?? 'agent-mascot'; const result = { url, date: new Date().toISOString(), checks: [], layouts: [], errors: [] }; const browser = await chromium.launch({ executablePath: '/usr/bin/chromium', headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); const page = await context.newPage(); page.on('pageerror', error => result.errors.push(error.message)); const requests = []; page.on('request', request => requests.push(request.url())); const ready = async () => { await page.waitForFunction(() => window.browserPi?.terminal); await page.evaluate(() => window.browserPi.ready); }; const visible = () => page.locator('.empty-mascot').waitFor({ state: 'visible' }); let releaseImage; try { const imageGate = new Promise(resolve => { releaseImage = resolve; }); await page.route('**/*empty-cat*.png', async route => { await imageGate; await route.continue(); }); await page.goto(url, { waitUntil: 'domcontentloaded' }); await ready(); await page.waitForFunction(() => document.querySelector('.empty-mascot img').hasAttribute('src')); assert(!await page.locator('.empty-mascot').isVisible()); assert(!await page.locator('.mascot-suggestion').isVisible()); releaseImage(); await visible(); await page.unroute('**/*empty-cat*.png'); await page.waitForFunction(() => document.querySelector('.empty-mascot img').naturalWidth > 0); result.checks.push('Bubble and cat stay hidden during a delayed image download and appear together when ready'); await page.locator('.mascot-suggestion').focus(); await page.keyboard.press('Tab'); assert(!await page.locator('.mascot-cat').evaluate(el => el.contains(document.activeElement))); await page.locator('.xterm-helper-textarea').focus(); await page.locator('.mascot-cat').evaluate(el => el.focus()); assert(await page.locator('.xterm-helper-textarea').evaluate(el => el === document.activeElement)); await page.locator('.mascot-cat').click(); assert(await page.locator('.mascot-cat').evaluate(el => el.getAnimations().some(a => a.id === 'mascot-bump'))); assert(await page.locator('.xterm-helper-textarea').evaluate(el => el === document.activeElement)); await page.locator('.mascot-cat').click(); assert.equal(await page.locator('.mascot-cat').evaluate(el => el.getAnimations().filter(a => a.id === 'mascot-bump').length), 1); assert.equal(await page.evaluate(() => window.browserPi.terminal.text), ''); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.waitForFunction(() => document.querySelector('.empty-mascot').getAnimations({ subtree: true }).length === 0); await page.locator('.mascot-cat').click(); assert.equal(await page.locator('.mascot-cat').evaluate(el => el.getAnimations().length), 0); await page.emulateMedia({ reducedMotion: 'no-preference' }); result.checks.push('Cat is not focusable and Tab skips it; clicking preserves input focus and replays one hop; reduced motion remains still'); await page.locator('.mascot-suggestion').click(); const prompt = await page.evaluate(() => window.browserPi.terminal.text); assert.equal(prompt, "What's our total sales revenue?"); assert(await page.locator('.xterm-helper-textarea').evaluate(el => el === document.activeElement)); assert.equal((await page.evaluate(() => window.browserPi.rpc('snapshot'))).messages.length, 0); await page.waitForFunction(() => document.querySelector('.mascot-suggestion').hidden); await page.locator('#send').click(); await page.locator('#model-dialog').waitFor({ state: 'visible' }); assert.equal(await page.locator('#model-preview').textContent(), prompt); await page.locator('#model-cancel').click(); await page.waitForFunction(prompt => window.browserPi.terminal.text === prompt, prompt); await visible(); assert(!await page.locator('.mascot-suggestion').isVisible()); assert(!requests.some(url => /\.onnx|tokenizer|manifest\.json/.test(url))); result.checks.push('Suggestion fills and focuses Pi without sending; first-message modal and Cancel preserve the draft and zero-user state'); await page.reload(); await ready(); await visible(); for (const [width, height] of [[1440,1000],[1100,800],[851,640],[850,640],[390,844],[320,568],[390,420]]) { await page.setViewportSize({ width, height }); await page.waitForTimeout(120); const layout = await page.evaluate(() => { const mascot = document.querySelector('.empty-mascot'), m = mascot.getBoundingClientRect(); const cat = document.querySelector('.mascot-cat').getBoundingClientRect(); const screen = document.querySelector('.xterm-screen').getBoundingClientRect(), t = window.browserPi.terminal; const lines = t.readScreen().split('\n'), status = lines.findIndex(line => line.includes('Files and chat')); const rowHeight = screen.height / t.rows; let lastContent = status - 1; while (lastContent >= 0 && !lines[lastContent].trim()) lastContent--; return { visible: !mascot.hidden, suggestionVisible: !mascot.hidden && !document.querySelector('.mascot-suggestion').hidden, top: m.top, bottom: m.bottom, left: m.left, right: m.right, contentBottom: screen.top + (lastContent + 1) * rowHeight, statusTop: screen.top + status * rowHeight, pawBottom: cat.top + cat.height * 1156 / 1250, dividerCenter: screen.top + (status + 1.5) * rowHeight, inputTop: screen.top + (status + 2) * rowHeight, screenLeft: screen.left, screenRight: screen.right, overflow: document.documentElement.scrollWidth > innerWidth }; }); assert(!layout.overflow); if ((width === 320 && height === 568) || (width === 390 && height === 844)) assert(layout.suggestionVisible, 'The first suggested task must be visible on a small phone'); if (layout.visible) { assert(layout.top >= layout.contentBottom && layout.bottom <= layout.inputTop, JSON.stringify(layout)); assert(Math.abs(layout.pawBottom - layout.dividerCenter) < 3, JSON.stringify(layout)); assert(layout.left >= layout.screenLeft && layout.right <= layout.screenRight + 1); } if ([1440,390,320].includes(width)) await page.screenshot({ path: `../validation/${label}-${width}x${height}.png`, fullPage: true }); result.layouts.push({ width, height, ...layout }); } result.checks.push('Seven viewports keep the cat on the input divider without covering the editor; short viewports prioritize the editor'); await page.setViewportSize({ width: 1440, height: 1000 }); await visible(); await page.evaluate(() => window.browserPi.rpc('shell', { command: 'rm /workspace/products.json' })); await page.waitForFunction(() => document.querySelector('.mascot-suggestion').textContent.includes('explore')); await page.evaluate(() => window.browserPi.rpc('shell', { command: 'rm /workspace/sales.csv' })); await page.waitForFunction(() => document.querySelector('.mascot-suggestion').textContent.includes('explore')); result.checks.push('Starter prompt follows actual workspace files'); // Seed history in this test-owned browser to exercise the real restore path. await page.evaluate(async () => { const saved = await window.browserPi.rpc('snapshot'); saved.messages = [{ role: 'user', timestamp: Date.now(), content: [{ type: 'text', text: 'Saved conversation' }] }]; const db = await new Promise((resolve, reject) => { const r = indexedDB.open('minicpm5-pi-workspace', 1); r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); }); await new Promise((resolve, reject) => { const tx = db.transaction('state', 'readwrite'); tx.objectStore('state').put(saved, 'current'); tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); }); db.close(); }); await page.reload(); await ready(); assert(!await page.locator('.empty-mascot').isVisible()); assert.equal(await page.locator('.empty-mascot img').getAttribute('src'), null); await page.locator('#new-chat').click(); await visible(); assert.match(await page.locator('.mascot-suggestion').textContent(), /explore/); result.checks.push('Restored user history hides the mascot without fetching its image; New chat brings it back'); await page.locator('.xterm-helper-textarea').focus(); for (let i = 0; i < 12; i++) { await page.keyboard.type(`Draft line ${i}`); await page.keyboard.press('Shift+Enter'); } await page.setViewportSize({ width: 390, height: 420 }); await page.waitForFunction(() => document.querySelector('.empty-mascot').hidden); assert((await page.evaluate(() => window.browserPi.terminal.readScreen())).includes('Draft line 11')); result.checks.push('Multiline drafts and keyboard-sized viewports stay unobstructed'); assert.deepEqual(result.errors, []); result.passed = true; console.log(JSON.stringify({ passed: true, checks: result.checks })); } catch (error) { result.failure = error.stack; await page.screenshot({ path: `../validation/${label}-failure.png`, fullPage: true }); throw error; } finally { releaseImage?.(); await fs.writeFile(`../validation/${label}.json`, JSON.stringify(result, null, 2)); await browser.close(); }