Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_railkeys.mjs

10.7 KiB, 1 run

created by r2519314175:625, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_railkeys.mjs — a Diamond and a chat can be opened from the keyboard.
2//
3// Both were severity-1 findings (dev/a11y_report.md §1 and §2) and both were
4// answered in the code afterwards, but nothing pressed the keys to say so: the
5// KNOWN DEFECT notes in verify_a11y_keyboard went on reporting them as live for
6// as long as the fixes had been shipped. A note left standing after its fix makes
7// the fix look like a regression the next time anyone reads it, and a fix nobody
8// tested is a fix nobody can rely on. This is the test.
9//
10// §1. A Diamond row was a <div> with a click handler whose only focusable child
11// was the x that deletes it, so tabbing the rail reached "delete this
12// Diamond" and never "open this Diamond": the destructive act had a keyboard
13// route and the central one did not.
14// §2. A chat's .tile-label is a readonly <input>, so Tab reached it -- but only a
15// mouse click selected the chat, and Enter was mapped to blur(), which is the
16// one thing that looks like it worked.
17//
18// Both are asserted by pressing the key and asking the APPLICATION what changed.
19// Each is then proved red by breaking the property in the live page.
20import { open, newChat } from './harness.mjs';
21
22const ok = [], bad = [];
23const check = (name, pass, detail) => {
24 (pass ? ok : bad).push(name);
25 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
26 return pass;
27};
28const red = (wentRed, what) => {
29 console.log((wentRed ? ' ok ' : ' FAIL ') + 'self-test: ' + what);
30 (wentRed ? ok : bad).push('self-test: ' + what);
31};
32
33// Connected to the mock: without a provider the composer never appears, so there
34// is no second chat to switch to and nothing to press a key on.
35const s = await open({ name: 'railkeys' });
36const p = s.page;
37await p.waitForTimeout(1500);
38
39// ── §2. Two chats, so "the chat changed" has an answer ──────────────
40await newChat(s);
41// The second one goes through the buttons directly. `newChat` returns early when
42// a composer is already visible, and driving it twice fights its own guard.
43await p.click('#new-session-btn', { force: true });
44await p.waitForTimeout(500);
45const start = p.locator('.tile-start').first();
46if (await start.count()) await start.click({ force: true });
47await p.waitForTimeout(900);
48
49const tiles = await p.evaluate(() => document.querySelectorAll('.tile-label').length);
50check('two chats are on the page to switch between', tiles >= 2, `${tiles} tiles`);
51
52/// WHICH chat is currently open, by id.
53///
54/// It read the centre header's TEXT, which worked only while every chat carried
55/// a distinct auto-generated name. Chats have no names now — the rail derives a
56/// relative time — so two chats made a second apart both read "the chat from
57/// just now" and "the open chat changed" had no answer. The id is what the
58/// question was always about, and the rail marks it with `.active`.
59const current = () => p.evaluate(() =>
60 ((document.querySelector('.session-box.chat-box.active') || {}).dataset || {}).id || '');
61
62const before = await current();
63// Focus the tile of the chat that is NOT open, and press Enter on it.
64const focused = await p.evaluate(() => {
65 const box = [...document.querySelectorAll('.session-box.chat-box')]
66 .find((e) => !e.classList.contains('active'));
67 const lab = box && box.querySelector('.tile-label');
68 if (!lab) return null;
69 lab.focus();
70 return document.activeElement === lab;
71});
72check('the other chat\'s tile takes the focus', focused === true);
73await p.keyboard.press('Enter');
74await p.waitForTimeout(800);
75const afterEnter = await current();
76check('Enter on a chat tile opens that chat', afterEnter !== before && !!afterEnter,
77 `${JSON.stringify(before)} -> ${JSON.stringify(afterEnter)}`);
78
79// And Space, which a control that answers Enter must also answer.
80await p.evaluate(() => {
81 const box = [...document.querySelectorAll('.session-box.chat-box')]
82 .find((e) => !e.classList.contains('active'));
83 const lab = box && box.querySelector('.tile-label');
84 if (lab) lab.focus();
85});
86await p.keyboard.press(' ');
87await p.waitForTimeout(800);
88const afterSpace = await current();
89check('Space on a chat tile opens that chat too', afterSpace === before,
90 `back to ${JSON.stringify(afterSpace)}`);
91
92// ── §1. A Diamond row is the control ────────────────────────────────
93// Through the dialog a person uses: the + in the rail, a name, Create.
94await p.evaluate(() => document.getElementById('new-diamond-btn').click());
95await p.waitForSelector('.dlg-card', { timeout: 8000 });
96const made = await p.evaluate(() => {
97 const card = [...document.querySelectorAll('.dlg-card')].find((c) => c.getClientRects().length);
98 const inp = card && card.querySelector('input.dlg-input');
99 if (!inp) return 'no name field';
100 inp.value = 'Keyboard';
101 inp.dispatchEvent(new Event('input', { bubbles: true }));
102 card.querySelector('.dlg-ok').click();
103 return 'ok';
104});
105check('a Diamond could be made to test with', made === 'ok', made);
106await p.waitForTimeout(1500);
107
108const rows = await p.evaluate(() => document.querySelectorAll('.diamond-box').length);
109check('a Diamond row is on the page to be tested', rows > 0, `${rows} rows`);
110
111const shape = await p.evaluate(() => {
112 const row = document.querySelector('.diamond-box');
113 if (!row) return null;
114 const FOCUS_SEL = 'a[href],button:not([disabled]),input:not([disabled]),'
115 + 'select:not([disabled]),textarea:not([disabled]),'
116 + '[tabindex]:not([tabindex="-1"]),summary,iframe,embed';
117 return {
118 role: row.getAttribute('role'),
119 focusable: row.matches(FOCUS_SEL),
120 label: row.getAttribute('aria-label'),
121 // The cog that replaced the closer cross, and whether it says WHICH row
122 // it is on: five tiles must not announce as five identical "Settings".
123 delLabel: (row.querySelector('.tile-cog') || {}).ariaLabel || null,
124 };
125});
126if (shape) {
127 check('a Diamond row is a button to the accessibility tree', shape.role === 'button', `role=${shape.role}`);
128 check('a Diamond row is in the tab order', shape.focusable === true);
129 check('a Diamond row says its own name', !!shape.label && shape.label.trim().length > 0,
130 JSON.stringify(shape.label));
131 // §5: two Diamonds must not present two identical controls.
132 check('the cog names the Diamond it would open the settings of',
133 !!shape.delLabel && shape.delLabel !== '×' && shape.delLabel.trim().length > 1,
134 JSON.stringify(shape.delLabel));
135}
136
137// Enter on the row selects it. With one Diamond the selection cannot move, so
138// what is asserted is that the KEY reached the row's own click path.
139const answered = await p.evaluate(() => {
140 const row = document.querySelector('.diamond-box');
141 if (!row) return null;
142 let fired = false;
143 const spy = () => { fired = true; };
144 row.addEventListener('click', spy);
145 row.focus();
146 row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
147 row.removeEventListener('click', spy);
148 return { fired, focused: document.activeElement === row };
149});
150if (answered) {
151 check('a Diamond row takes the focus', answered.focused === true);
152 check('Enter on a Diamond row acts on the row', answered.fired === true);
153}
154
155// ── Proved red ──────────────────────────────────────────────────────
156console.log('');
157
158const r1 = rows === 0 ? null : await p.evaluate(() => {
159 const row = document.querySelector('.diamond-box');
160 const was = row.getAttribute('tabindex');
161 row.removeAttribute('tabindex');
162 const seen = row.matches('[tabindex]:not([tabindex="-1"])');
163 row.setAttribute('tabindex', was);
164 return seen;
165});
166red(r1 === false, 'a Diamond row with its tabindex stripped fails the tab-order check');
167
168// A clone keeps the attributes and loses the listeners: the row still LOOKS
169// operable and answers nothing, which is the regression this file is for.
170const r2 = await p.evaluate(() => {
171 const row = document.querySelector('.diamond-box');
172 const twin = row.cloneNode(true);
173 row.parentNode.replaceChild(twin, row);
174 let fired = false;
175 twin.addEventListener('click', () => { fired = true; });
176 twin.focus();
177 twin.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
178 return fired;
179});
180red(r2 === false, 'a Diamond row that kept its ARIA but lost its keydown answers nothing');
181
182// And the chat tile: THE LABEL IS A CONTROL, NOT A FIELD.
183//
184// This check replaces one that no longer means anything. The label used to be a
185// readonly `<input>` that a double-click made editable, so Enter had two jobs —
186// open the chat, or commit a rename — and the old self-test proved the second
187// branch existed. The rename gesture has left the tile entirely (renaming a chat
188// is now "Keep as a Diamond", with a plain rename two clicks in behind the cog),
189// so the branch is gone and the old test could only ever pass: `readOnly` on a
190// `<button>` sets a property nothing reads.
191//
192// What CAN regress is somebody putting the field back, at which point Tab
193// through the rail lands in a text box again and the a11y §2 finding returns. So
194// that is what is asserted: no tile carries an editable field, and the label is
195// a real button.
196const r3 = await p.evaluate(() => {
197 const box = [...document.querySelectorAll('.session-box.chat-box')].find(Boolean);
198 if (!box) return null;
199 const lab = box.querySelector('.tile-label');
200 return {
201 isButton: !!lab && lab.tagName === 'BUTTON',
202 fields: box.querySelectorAll('input[type=text], input:not([type]), textarea').length,
203 };
204});
205check('a chat tile\'s label is a button, not a field to type in',
206 !!r3 && r3.isButton === true, JSON.stringify(r3));
207check('and the tile carries no editable field for Tab to land in',
208 !!r3 && r3.fields === 0, JSON.stringify(r3));
209// The same check, run against a tile that HAS the old field put back, so it is
210// proved to notice rather than merely to pass.
211const r3red = await p.evaluate(() => {
212 const box = [...document.querySelectorAll('.session-box.chat-box')].find(Boolean);
213 if (!box) return null;
214 const twin = box.cloneNode(true);
215 const lab = twin.querySelector('.tile-label');
216 const inp = document.createElement('input');
217 inp.className = 'tile-label';
218 inp.readOnly = true;
219 if (lab) lab.replaceWith(inp);
220 return {
221 isButton: inp.tagName === 'BUTTON',
222 fields: twin.querySelectorAll('input[type=text], input:not([type]), textarea').length,
223 };
224});
225red(!!r3red && (r3red.isButton === false || r3red.fields > 0),
226 'a tile with the old rename field put back fails both label checks');
227
228console.log('');
229console.log(bad.length
230 ? `${bad.length} FAILED of ${ok.length + bad.length}:\n - ${bad.join('\n - ')}`
231 : `rail keyboard: all ${ok.length} checks pass — a Diamond and a chat both open from the keyboard.`);
232await s.close();
233process.exit(bad.length ? 1 : 0);