aboutsummaryrefslogtreecommitdiff
path: root/tests/gallery-probes/probe.mjs
blob: d19637732a273b2ccf67b852684cb29b642e34ab (plain)
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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
// CDP probe for the widget gallery — no playwright, Node global WebSocket/fetch.
// Usage: node probe.mjs [--shot out.png] [--size N]
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs';

const PORT = 9333;
const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html';
const shotIdx = process.argv.indexOf('--shot');
const shotPath = shotIdx > -1 ? process.argv[shotIdx + 1] : null;

const chrome = spawn('google-chrome-stable', [
  '--headless=new', `--remote-debugging-port=${PORT}`,
  '--no-first-run', '--no-default-browser-check',
  '--window-size=1600,1200', URL,
], { stdio: 'ignore' });

const sleep = ms => new Promise(r => setTimeout(r, ms));
let ws, id = 0;
const pending = new Map();
const events = [];

async function connect() {
  for (let i = 0; i < 40; i++) {
    try {
      const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
      const page = list.find(t => t.type === 'page' && t.url.startsWith('file'));
      if (page) { ws = new WebSocket(page.webSocketDebuggerUrl); break; }
    } catch { /* retry */ }
    await sleep(250);
  }
  if (!ws) throw new Error('no page target');
  await new Promise(r => ws.onopen = r);
  ws.onmessage = m => {
    const d = JSON.parse(m.data);
    if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); }
    else if (d.method) events.push(d);
  };
}
function send(method, params = {}) {
  return new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
}
async function evl(expr) {
  const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true });
  if (r.result.exceptionDetails) throw new Error('eval failed: ' + JSON.stringify(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text));
  return r.result.result.value;
}
async function drag(x1, y1, x2, y2) {
  await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: x1, y: y1, button: 'left', clickCount: 1 });
  const steps = 8;
  for (let i = 1; i <= steps; i++)
    await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: x1 + (x2 - x1) * i / steps, y: y1 + (y2 - y1) * i / steps, button: 'left' });
  await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: x2, y: y2, button: 'left', clickCount: 1 });
}

const fails = [];
const ok = (name, cond, detail = '') => { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) fails.push(name); };

try {
  await connect();
  await send('Runtime.enable');
  await send('Page.enable');
  await sleep(1500);

  // 0. console errors / exceptions
  const errs = events.filter(e => e.method === 'Runtime.exceptionThrown');
  ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200));

  // 1. defaults: size=2 (M), card count
  ok('default size 2', await evl(`document.body.dataset.size`) === '2');
  const cards = await evl(`document.querySelectorAll('.card').length`);
  ok('109 cards', cards === 109, `got ${cards}`);

  // 2. zoom actually scales: card visual width at 3x vs 1x
  await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`);
  const w3 = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
  await evl(`document.querySelector('.szbar .key[data-sz="1"]').click()`);
  const w1 = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
  ok('3x wider than 1x', w3 > w1 * 1.8, `w3=${Math.round(w3)} w1=${Math.round(w1)}`);
  ok('size chip flips state', await evl(`document.body.dataset.size`) === '1');

  // 3. behavioral at 3x: fader drag on card 03 (horizontal fader) changes readout
  await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`);
  await evl(`document.querySelectorAll('.card')[2].scrollIntoView({block:'center'}); ''`);
  await sleep(200);
  const fr = await evl(`(()=>{const c=document.querySelectorAll('.card')[2];const f=c.querySelector('.fader');const r=f.getBoundingClientRect();return [r.left,r.top,r.width,r.height];})()`);
  const before = await evl(`document.getElementById('rd-03').textContent`);
  await drag(fr[0] + fr[2] * 0.2, fr[1] + fr[3] / 2, fr[0] + fr[2] * 0.9, fr[1] + fr[3] / 2);
  await sleep(150);
  const after = await evl(`document.getElementById('rd-03').textContent`);
  ok('fader drag tracks at 3x', before !== after && after !== '—', `'${before}' -> '${after}'`);

  // 4. behavioral at 3x: toggle click on card 01
  await evl(`document.querySelectorAll('.card')[0].scrollIntoView({block:'center'}); ''`);
  await sleep(200);
  const sw = await evl(`(()=>{const c=document.querySelectorAll('.card')[0];const s=c.querySelector('.switch')||c.querySelector('.stagew > *');const r=s.getBoundingClientRect();return [r.left+r.width/2,r.top+r.height/2];})()`);
  const t0 = await evl(`document.getElementById('rd-01').textContent`);
  await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: sw[0], y: sw[1], button: 'left', clickCount: 1 });
  await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sw[0], y: sw[1], button: 'left', clickCount: 1 });
  await sleep(150);
  const t1 = await evl(`document.getElementById('rd-01').textContent`);
  ok('toggle click responds at 3x', t0 !== t1, `'${t0}' -> '${t1}'`);

  // 5. card 02 console keys: reading order and per-key tone. LIVE is green
  //    because --pass is what the kit means by live everywhere else; gold stays
  //    the generic engaged look. Craig's call, 2026-07-16.
  const order = await evl(`[...document.querySelectorAll('#card-02 .key')].map(b => b.textContent)`);
  ok('card 02 keys read SCAN, LIVE, MUTED',
    JSON.stringify(order) === JSON.stringify(['SCAN', 'LIVE', 'MUTED']), JSON.stringify(order));

  const engaged = await evl(`(()=>{
    const lit = [...document.querySelectorAll('#card-02 .key')].filter(b => /\\b(on|green|red)\\b/.test(b.className));
    return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('key ','') : 'lit=' + lit.length;
  })()`);
  ok('card 02 engages LIVE in green by default', engaged === 'LIVE:green', engaged);

  const muted = await evl(`(()=>{
    const keys = [...document.querySelectorAll('#card-02 .key')];
    keys.find(b => b.textContent === 'MUTED').click();
    const lit = keys.filter(b => /\\b(on|green|red)\\b/.test(b.className));
    return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('key ','') : 'lit=' + lit.length;
  })()`);
  ok('card 02 MUTED engages red and releases LIVE', muted === 'MUTED:red', muted);

  const rd02 = await evl(`document.getElementById('rd-02').textContent`);
  ok('card 02 readout tracks the engaged key', rd02 === 'MUTED', rd02);

  // 6. card 01 slide toggle: the new tone atoms exist. red/warn on the `on` axis
  //    let the ENGAGED state be the notable one (mute, record, airplane) — before
  //    these, red was only reachable via the `off` axis, which colours the
  //    disengaged state instead. dim offText lets an off toggle recede in a dense
  //    panel. Craig's call, 2026-07-16.
  const atoms = await evl(`(()=>{
    const S = GW.slideToggle.STYLES;
    const want = { on: ['amber','green','dark','red','warn'], onText: ['panel','cream','green'],
                   off: ['dark','red'], offText: ['white','red','black','dim'],
                   thumb: ['light','dark','chrome','brass'] };
    const missing = [];
    for (const [axis, names] of Object.entries(want))
      for (const n of names) if (!S[axis] || !S[axis][n]) missing.push(axis + '.' + n);
    return missing.length ? 'missing: ' + missing.join(', ') : 'ok';
  })()`);
  ok('slide toggle style atoms present', atoms === 'ok', atoms);

  // 7. presets name intent (a combination), not paint. Every axis a preset names
  //    must resolve in STYLES — a typo here would silently fall through to the
  //    stylesheet default and look "nearly right", which is the worst outcome.
  const presets = await evl(`(()=>{
    const P = GW.slideToggle.PRESETS, S = GW.slideToggle.STYLES;
    if (!P) return 'no PRESETS';
    const AX = GW.slideToggle.AXIS_ORDER;
    const bad = [];
    for (const [name, p] of Object.entries(P)) {
      for (const ax of AX) {
        if (!p[ax]) bad.push(name + ' missing ' + ax);
        else if (!S[ax][p[ax]]) bad.push(name + '.' + ax + '="' + p[ax] + '" not in STYLES');
      }
    }
    return bad.length ? bad.join('; ') : Object.keys(P).join(',');
  })()`);
  ok('every preset names a resolvable style on every axis',
    presets === 'panel,run,armed,caution,dark', presets);

  // 7a. the dark preset: neither state lights the pill, so the legend colour is
  //     the only thing carrying state. Both backgrounds must stay dark while the
  //     inks diverge — if either pill lights, the preset has lost its point.
  const dark = await evl(`(()=>{
    const P = GW.slideToggle.PRESETS.dark, S = GW.slideToggle.STYLES;
    const onBg = S.on[P.on].vars['--sw-on-bg'], offBg = S.off[P.off].vars['--sw-off-bg'];
    const onInk = S.onText[P.onText].vars['--sw-on-ink'], offInk = S.offText[P.offText].vars['--sw-off-ink'];
    if (onBg !== offBg) return 'pills differ: on=' + onBg + ' off=' + offBg;
    if (onInk === offInk) return 'inks identical, state unreadable: ' + onInk;
    return 'ok';
  })()`);
  ok('dark preset keeps both pills dark and the inks distinct', dark === 'ok', dark);

  // 7b. the card claims the preset it is actually in. The widget defaults to
  //     `panel`, so a preset group with nothing lit would assert "no preset
  //     active" — false, and exactly the kind of quiet mislabel this walk exists
  //     to catch. Nothing above this point touches card 01's chips (checks 2-4
  //     only toggle its switch, which does not restyle), so the default still
  //     stands here — but this must stay ABOVE checks 8/8b/9, which do mutate
  //     the chips. Insert preset-touching checks after them, not before.
  const defaultPreset = await evl(`(()=>{
    const pg = [...document.querySelectorAll('#card-01 .fgroup')]
      .find(g => g.querySelector('.lab')?.textContent === 'preset');
    if (!pg) return 'no preset group';
    const lit = [...pg.querySelectorAll('.fc')].filter(c => c.classList.contains('on'));
    return lit.length === 1 ? lit[0].title : 'lit=' + lit.length;
  })()`);
  ok('card 01 defaults to the panel preset', defaultPreset === 'panel', defaultPreset);

  // 8. a preset chip drives the widget AND re-syncs the axis chips, so the card
  //    never shows a combination the widget isn't in.
  const applied = await evl(`(()=>{
    const card = document.getElementById('card-01');
    const groups = [...card.querySelectorAll('.fgroup')];
    const pg = groups.find(g => g.querySelector('.lab')?.textContent === 'preset');
    if (!pg) return 'no preset group';
    const armed = [...pg.querySelectorAll('.fc')].find(c => c.title === 'armed');
    if (!armed) return 'no armed chip';
    armed.click();
    const onGroup = groups.find(g => g.querySelector('.lab')?.textContent === 'on');
    const lit = [...onGroup.querySelectorAll('.fc')].filter(c => c.classList.contains('on'));
    const brd = card.querySelector('.switch').style.getPropertyValue('--sw-on-brd');
    return (lit.length === 1 ? lit[0].title : 'lit=' + lit.length) + '|' + brd;
  })()`);
  ok('armed preset drives widget and syncs the on chip', applied === 'red|var(--fail)', applied);

  // 8b. axes are not independent: onText overrides the ink `on` sets, so changing
  //     `on` after picking an onText must not silently revert the legend while its
  //     chip still shows lit. Drive it in the hazardous order and check the ink.
  const orderSafe = await evl(`(()=>{
    const card = document.getElementById('card-01');
    const groups = [...card.querySelectorAll('.fgroup')];
    const grp = l => groups.find(g => g.querySelector('.lab')?.textContent === l);
    const chip = (l, t) => [...grp(l).querySelectorAll('.fc')].find(c => c.title === t);
    chip('on text', 'green').click();   // legend green
    chip('on', 'dark').click();         // then change the pill — must keep it
    const ink = card.querySelector('.switch').style.getPropertyValue('--sw-on-ink');
    const lit = [...grp('on text').querySelectorAll('.fc')].filter(c => c.classList.contains('on'));
    return ink + '|' + (lit.length === 1 ? lit[0].title : 'lit=' + lit.length);
  })()`);
  ok('changing on keeps the chosen onText (chips cannot lie)',
    orderSafe === 'var(--sevgrn)|green', orderSafe);

  // 9. diverging on one axis clears the preset — the card stops claiming a preset
  //    it is no longer in. Re-establishes its own precondition (preset lit) rather
  //    than inheriting it: the checks above already clicked axis chips, each of
  //    which clears the preset, so asserting lit===0 without re-lighting first
  //    would pass against an already-cleared group and prove nothing — including
  //    against a regression where clearing fired for onText but not for `on`.
  const diverged = await evl(`(()=>{
    const groups = [...document.querySelectorAll('#card-01 .fgroup')];
    const grp = l => groups.find(g => g.querySelector('.lab')?.textContent === l);
    const litPresets = () => [...grp('preset').querySelectorAll('.fc')].filter(c => c.classList.contains('on')).length;
    [...grp('preset').querySelectorAll('.fc')].find(c => c.title === 'run').click();
    const before = litPresets();
    [...grp('on').querySelectorAll('.fc')].find(c => c.title === 'green').click();
    return before + '->' + litPresets();
  })()`);
  ok('changing an axis clears the preset selection', diverged === '1->0', diverged);

  // late exceptions from interactions
  const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
  ok('no exceptions after interaction', errs2.length === 0);

  if (shotPath) {
    await evl(`window.scrollTo(0,0)`);
    await sleep(300);
    const shot = await send('Page.captureScreenshot', { format: 'png' });
    writeFileSync(shotPath, Buffer.from(shot.result.data, 'base64'));
    console.log('shot: ' + shotPath);
  }
} catch (e) {
  console.error('PROBE ERROR: ' + e.message);
  fails.push('probe-error');
} finally {
  chrome.kill();
}
process.exit(fails.length ? 1 : 0);