File size: 9,800 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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
import { chromium, webkit } 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-modal-cat';
const result = { url, checks: [], layouts: [], errors: [] };
const webkitRoot = process.env.WEBKIT_MINIBROWSER_ROOT;
for (const engine of ['chromium', ...(process.env.TEST_WEBKIT ? ['webkit'] : [])]) {
  const browser = engine === 'chromium'
    ? await chromium.launch({ executablePath: '/usr/bin/chromium', headless: true, args: ['--disable-gpu', '--disable-software-rasterizer'] })
    : await webkit.launch({ headless: true, ...(webkitRoot ? { executablePath: webkitRoot + '/bin/MiniBrowser', env: { ...process.env,
      WEBKIT_EXEC_PATH: webkitRoot + '/bin', WEBKIT_INJECTED_BUNDLE_PATH: webkitRoot + '/lib', WEBKIT_INSPECTOR_RESOURCES_PATH: webkitRoot + '/share',
      LD_LIBRARY_PATH: webkitRoot + '/lib:' + webkitRoot + '/sys/lib:' + (process.env.WEBKIT_EXTRA_LIBS ?? '') } } : {}) });
  const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
  const requests = [];
  const catRequests = [];
  page.on('request', r => {
    requests.push(r.url());
    if (r.url().includes('modal-cat') && !r.redirectedFrom()) catRequests.push(r.url());
  });
  page.on('pageerror', e => result.errors.push(e.message));
  let releaseImage;
  try {
    const imageGate = new Promise(resolve => { releaseImage = resolve; });
    await page.route('**/*modal-cat*.png', async route => { await imageGate; await route.continue(); });
    await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.browserPi?.terminal); await page.evaluate(() => window.browserPi.ready);
    assert(requests.some(u => u.includes('modal-cat')), 'The sprite must start loading before any interaction');
    assert(await page.locator('link[rel="preload"][as="image"]').evaluate(link => link.href === document.querySelector('.setup-cat img').src));
    const prompt = "What's our total sales revenue?";
    await page.locator('.xterm-helper-textarea').focus(); await page.keyboard.type(prompt); await page.locator('#send').click();
    await page.locator('#model-dialog[open]').waitFor();
    assert.equal(await page.locator('.setup-cat img').evaluate(el => getComputedStyle(el).opacity), '0');
    assert(await page.locator('#model-confirm').evaluate(el => document.activeElement === el));
    await page.locator('#model-cancel').focus(); await page.keyboard.press('Enter');
    assert.equal(await page.evaluate(() => window.browserPi.terminal.text), prompt);
    releaseImage();
    await page.waitForFunction(() => document.querySelector('.setup-cat').classList.contains('is-ready'));
    assert(await page.locator('#model-dialog').isHidden());
    assert.equal(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().length), 0);
    await page.evaluate(() => {
      const dialog = document.querySelector('#model-dialog');
      const observer = new MutationObserver(() => {
        if (!dialog.open) return;
        const img = dialog.querySelector('.setup-cat img');
        window.catAtModalOpen = img.complete && img.naturalWidth > 0 && getComputedStyle(img).opacity === '1';
        observer.disconnect();
      });
      observer.observe(dialog, { attributes: true, attributeFilter: ['open'] });
    });
    await page.locator('#send').click();
    await page.locator('#model-dialog[open]').waitFor();
    assert.equal(await page.evaluate(() => window.catAtModalOpen), true, 'The decoded cat must be visible at modal opening');
    assert.equal(await page.locator('#model-cancel').getAttribute('aria-label'), 'Close model setup');
    assert.equal(await page.locator('#model-cancel svg').count(), 1);
    await page.keyboard.press('Shift+Tab');
    assert(await page.locator('.setup-credits a:last-child').evaluate(el => document.activeElement === el));
    await page.keyboard.press('Tab');
    assert(await page.locator('#model-confirm').evaluate(el => document.activeElement === el));
    assert.equal(catRequests.length, 1, 'Preload and image must reuse the same request');
    assert.equal(await page.locator('.setup-cat img').getAttribute('alt'), '');
    assert.equal(await page.locator('.setup-cat').getAttribute('aria-hidden'), 'true');
    // Spaces may redirect assets to a CDN, which taints a DOM image's canvas.
    // Inspect the same response bytes without changing the displayed sprite.
    const sprite = await page.request.get(await page.locator('.setup-cat img').evaluate(img => img.currentSrc));
    assert(sprite.ok());
    const alpha = await page.evaluate(async source => {
      const img = new Image(); img.src = source; await img.decode();
      const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
      const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
      return [[0,0],[1253,0],[0,1253],[1253,1253]].map(([x,y]) => ctx.getImageData(x,y,1,1).data[3]);
    }, 'data:image/png;base64,' + (await sprite.body()).toString('base64'));
    assert.deepEqual(alpha, [0,0,0,0]);
    result.checks.push(engine + ': sprite preloads once at page startup and decodes while closed; visible at modal opening; slow image does not block Cancel; transparent PNG adds no focus stop');
    const tailFrames = await page.locator('.setup-cat-tail').evaluate(el => {
      const animation = el.getAnimations()[0]; animation.pause();
      return [0, 2970, 3690, 4500].map(time => {
        animation.currentTime = time;
        return { transform: getComputedStyle(el).transform, body: getComputedStyle(el.nextElementSibling).transform };
      });
    });
    assert(tailFrames.every(frame => frame.body === 'none'));
    assert.notEqual(tailFrames[0].transform, tailFrames[1].transform);
    assert.notEqual(tailFrames[1].transform, tailFrames[2].transform);
    assert.equal(tailFrames[0].transform, tailFrames[3].transform);
    await page.emulateMedia({ reducedMotion: 'reduce' });
    assert.equal(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().length), 0);
    await page.emulateMedia({ reducedMotion: 'no-preference' });
    result.checks.push(engine + ': only the tail swishes and settles; animation is absent while closed or with reduced motion');
    for (const [width, height] of [[1440,1000],[1024,768],[850,640],[390,844],[375,667],[320,568],[390,420],[390,320]]) {
      await page.setViewportSize({ width, height });
      const layout = await page.evaluate(() => {
        const rect = s => document.querySelector(s).getBoundingClientRect().toJSON();
        return { dialog: rect('#model-dialog'), cat: rect('.setup-cat'), title: rect('#model-title'), actions: rect('.setup-actions'), confirm: rect('#model-confirm'), close: rect('#model-cancel'), body: rect('.setup-body'), facts: rect('.setup-facts'),
          overflow: document.documentElement.scrollWidth > innerWidth,
          blend: getComputedStyle(document.querySelector('.setup-cat img')).mixBlendMode };
      });
      assert(!layout.overflow);
      assert(layout.cat.top >= 8 && layout.cat.right <= width - 8, JSON.stringify(layout));
      assert(layout.dialog.bottom <= height - 8 && layout.dialog.left >= 8, JSON.stringify(layout));
      assert(layout.actions.bottom <= height - 8 && layout.actions.top >= layout.dialog.top, JSON.stringify(layout));
      assert(layout.cat.bottom <= layout.title.top + 1, JSON.stringify(layout));
      assert(Math.abs(layout.confirm.width - layout.body.width) < 1, 'Primary action should fill the modal content width');
      assert(layout.close.right <= layout.dialog.right && layout.close.bottom <= layout.body.top && layout.close.left >= layout.title.right, JSON.stringify(layout));
      if (height >= 568) assert(layout.facts.bottom <= layout.body.bottom + 1, 'The full download note should fit: ' + JSON.stringify(layout));
      assert.equal(layout.blend, 'normal');
      await page.screenshot({ path: `../validation/${label}-${engine}-${width}x${height}.png` });
      result.layouts.push({ engine, width, height, ...layout });
    }
    await page.setViewportSize({ width: 390, height: 420 });
    await page.locator('#model-confirm').click();
    await page.locator('#model-error').waitFor({ state: 'visible' });
    assert.match(await page.locator('#model-error').textContent(), /WebGPU/);
    assert.equal(await page.locator('#model-confirm').textContent(), 'Try again');
    assert.equal(await page.locator('#model-cancel svg').count(), 1);
    assert(await page.locator('#model-cancel').isVisible());
    assert(await page.locator('.setup-cat.is-ready').isVisible());
    await page.screenshot({ path: `../validation/${label}-${engine}-error.png` });
    await page.locator('#model-cancel').click();
    assert.equal(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().length), 0);
    assert.equal(await page.evaluate(() => window.browserPi.terminal.text), prompt);
    assert(!requests.some(u => /\/resolve\/|\/models\//.test(u)));
    result.checks.push(engine + ': eight viewport sizes keep the cat and actions in bounds; real GPU failure keeps retry and cancel usable');
  } catch (e) {
    result.failure = e.stack; process.exitCode = 1;
    await page.screenshot({ path: `../validation/${label}-${engine}-failure.png` }).catch(() => {});
  } finally {
    releaseImage?.(); await browser.close();
    await fs.writeFile(`../validation/${label}.json`, JSON.stringify(result, null, 2));
  }
}
result.passed = !result.failure && !result.errors.length;
await fs.writeFile(`../validation/${label}.json`, JSON.stringify(result, null, 2));
console.log(JSON.stringify({ passed: result.passed, checks: result.checks, failure: result.failure }));