File size: 9,262 Bytes
39371ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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(); }