File size: 8,559 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
136
137
138
139
140
141
142
143
import { chromium, webkit } from 'playwright';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';

const url = process.argv[2] ?? 'http://127.0.0.1:4173/';
const result = { url, checks: [], errors: [] };
const webkitRoot = process.env.WEBKIT_MINIBROWSER_ROOT;
const cases = [
  { name: 'chromium-desktop', engine: chromium, mobile: false },
  { name: 'chromium-touch', engine: chromium, mobile: true },
  ...(process.env.TEST_WEBKIT ? [{ name: 'webkit-touch', engine: webkit, mobile: true }] : []),
];
for (const { name, engine, mobile } of cases.filter(item => !process.env.CAT_TEST_CASE || item.name === process.env.CAT_TEST_CASE)) {
  console.log(`Checking ${name}`);
  const browser = await engine.launch({ headless: true, ...(engine === chromium ? { executablePath: '/usr/bin/chromium' }
    : 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: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 },
    isMobile: mobile, hasTouch: mobile });
  page.setDefaultTimeout(20000);
  page.on('pageerror', error => result.errors.push(`${name}: ${error.message}`));
  // Observe real HTMLAudioElement playback; do not mock play() or decoding.
  await page.addInitScript(() => {
    const NativeAudio = window.Audio;
    window.catAudioInstances = [];
    window.Audio = function (...args) {
      const audio = new NativeAudio(...args);
      if (String(args[0]).includes('cat-meow')) window.catAudioInstances.push(audio);
      return audio;
    };
    window.Audio.prototype = NativeAudio.prototype;
  });
  const activate = selector => mobile ? page.locator(selector).tap() : page.locator(selector).click();
  const heard = () => page.waitForFunction(() => {
    const audio = window.catAudioInstances[0];
    return !audio.paused && audio.currentTime > .025 && !audio.muted && audio.volume > 0;
  });
  const ended = () => page.waitForFunction(() => window.catAudioInstances[0].ended);
  async function unfocusable(selector) {
    const states = await page.locator(selector).evaluate(figure => {
      const focused = document.activeElement;
      const nodes = [figure, ...figure.querySelectorAll('*')];
      const states = nodes.map(node => {
        node.focus();
        const style = getComputedStyle(node);
        return { keepsFocus: document.activeElement === focused, tabIndex: node.tabIndex,
          selection: style.userSelect, drag: style.webkitUserDrag, outline: style.outlineStyle };
      });
      for (const type of ['pointerdown', 'mousedown']) {
        const event = type === 'pointerdown'
          ? new PointerEvent(type, { bubbles: true, cancelable: true, isPrimary: true })
          : new MouseEvent(type, { bubbles: true, cancelable: true });
        figure.querySelector('img').dispatchEvent(event);
        states.push({ type, prevented: event.defaultPrevented });
      }
      return states;
    });
    for (const state of states) {
      if (state.type) assert(state.prevented, `${selector}: ${state.type} must preserve focus`);
      else {
        assert(state.keepsFocus && state.tabIndex < 0, `${selector}: cat layer took focus`);
        assert.equal(state.selection, 'none'); assert.equal(state.drag, 'none'); assert.equal(state.outline, 'none');
      }
    }
  }
  async function tabPastCat(start) {
    await page.locator(start).focus();
    for (let i = 0; i < 14; i++) {
      await page.keyboard.press('Tab');
      assert(!await page.evaluate(() => document.activeElement.closest('.mascot-cat, .setup-cat')));
    }
    await page.locator(start).focus();
  }
  try {
    console.log(`${name}: opening app`);
    await page.goto(url);
    await page.waitForFunction(() => window.browserPi?.terminal);
    await page.evaluate(() => window.browserPi.ready);
    await page.locator('.mascot-cat').waitFor({ state: 'visible' });
    console.log(`${name}: preloading audio`);
    await page.waitForFunction(() => window.catAudioInstances[0]?.readyState >= 2);
    await page.waitForTimeout(2200); // Includes the automatic greeting hop.
    assert.deepEqual(await page.evaluate(() => window.catAudioInstances.map(a => ({ paused: a.paused, time: a.currentTime }))), [{ paused: true, time: 0 }]);
    await page.locator('.xterm-helper-textarea').focus();
    await unfocusable('.mascot-cat');
    await tabPastCat('.xterm-helper-textarea');
    console.log(`${name}: playing empty-state meow`);
    await activate('.mascot-cat'); await heard();
    assert(await page.locator('.xterm-helper-textarea').evaluate(el => el === document.activeElement));
    await ended();
    await page.emulateMedia({ reducedMotion: 'reduce' });
    await activate('.mascot-cat'); await heard();
    assert.equal(await page.locator('.mascot-cat').evaluate(el => el.getAnimations().length), 0);
    await ended();
    await page.emulateMedia({ reducedMotion: 'no-preference' });
    await page.locator('.xterm-helper-textarea').focus();
    await page.keyboard.type('Hello cat'); await page.locator('#send').click();
    await page.locator('#model-dialog[open]').waitFor();
    await page.locator('.setup-cat.is-ready').waitFor();
    await unfocusable('.setup-cat');
    await tabPastCat('#model-confirm');
    console.log(`${name}: playing modal meow and swish`);
    await page.locator('.setup-cat').evaluate(el => el.focus());
    assert(await page.locator('#model-confirm').evaluate(el => el === document.activeElement));
    const bodyPose = await page.locator('.setup-cat img').evaluate(el => getComputedStyle(el).transform);
    await activate('.setup-cat'); await heard();
    assert(await page.locator('#model-confirm').evaluate(el => el === document.activeElement));
    assert(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().some(a => a.id === 'cat-tail-reaction')));
    const pose = await page.locator('.setup-cat-tail').evaluate(el => getComputedStyle(el).transform);
    await page.waitForTimeout(250);
    assert.notEqual(await page.locator('.setup-cat-tail').evaluate(el => getComputedStyle(el).transform), pose);
    assert.equal(await page.locator('.setup-cat img').evaluate(el => getComputedStyle(el).transform), bodyPose);
    await activate('.setup-cat'); await activate('.setup-cat'); await heard();
    assert.equal(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().filter(a => a.id === 'cat-tail-reaction').length), 1);
    assert.equal(await page.evaluate(() => window.catAudioInstances.length), 1);
    await ended();
    await page.emulateMedia({ reducedMotion: 'reduce' });
    await page.waitForFunction(() => document.querySelector('.setup-cat-tail').getAnimations().length === 0);
    await activate('.setup-cat'); await heard(); await ended();
    assert.equal(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().length), 0);
    await page.emulateMedia({ reducedMotion: 'no-preference' });
    await activate('.setup-cat'); await heard();
    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), 'Hello cat');
    await ended();
    // A blocked playback promise is swallowed; the UI reaction still works.
    await page.evaluate(() => { window.catAudioInstances[0].play = () => Promise.reject(new DOMException('Blocked', 'NotAllowedError')); });
    await page.locator('#send').click();
    await activate('.setup-cat');
    assert(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().some(a => a.id === 'cat-tail-reaction')));
    await page.screenshot({ path: `../validation/cat-interactions-${name}.png` });
    result.checks.push(`${name}: both cats and their layers reject focus, Tab skips cats, pointer/mouse preserve focus, no selection/drag/outline; real meow playback, repeated taps, silent idle, one shared clip, tail-only motion, reduced motion, close cleanup, blocked-audio fallback`);
  } finally { await browser.close(); }
}
assert.deepEqual(result.errors, []);
result.passed = true;
await fs.writeFile('../validation/cat-interactions.json', JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));