Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_diamonds.mjs

9.0 KiB, 1 run

created by r2519314175:367, 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_diamonds.mjs — a Diamond made in one window must appear in the other.
2//
3// Chats have carried a cross-tab signal for a long time: they live in
4// localStorage, which fires `storage` in every other tab of the origin.
5// Diamonds live in OPFS, which fires nothing at all — so two windows of the
6// same account each held their own idea of the rail, for ever. The user's
7// report was "my second Diamond disappeared, and re-creating it does nothing":
8// two windows open, each blind to the other's writes.
9//
10// This drives two REAL pages in ONE browser profile, which is what two windows
11// of one account actually are, and asks the question a person would ask: is it
12// there in the other window, without reloading it?
13//
14// Three scenarios, one fresh profile each, so no scenario inherits the state of
15// the one before it:
16// 1. create in A → shows in B
17// 2. delete in B → goes from A
18// 3. create while the rail is filtered → the new Diamond is visible anyway
19//
20// It also covers the two silent failures beside it: a create whose read-back
21// finds nothing must SAY so, and a create must never land behind a filter.
22import { open, signInAs, connectMock, shot, scratch, PASS, APP } from './harness.mjs';
23import fs from 'node:fs';
24
25// How long another window is allowed to take to notice. The `storage` event is
26// synchronous-ish, but the listener re-reads OPFS, and a cold OPFS read on a
27// loaded machine is not instant.
28const SETTLE = 2600;
29
30const ok = [], bad = [], skipped = [];
31const check = (name, pass, detail) => {
32 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
33 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
34};
35
36/// The names in a page's Diamond rail.
37const rail = (p) => p.evaluate(() =>
38 [...document.querySelectorAll('#diamond-list .diamond-box .session-box-name')]
39 .map(n => (n.textContent || '').trim()));
40
41/// Wait for the rail to satisfy `fn`, or give up and return what it holds.
42async function railUntil(p, fn, ms = SETTLE) {
43 const t0 = Date.now();
44 let last = [];
45 while (Date.now() - t0 < ms) {
46 last = await rail(p);
47 if (fn(last)) return last;
48 await p.waitForTimeout(150);
49 }
50 return last;
51}
52
53/// Make a Diamond through the dialog a person uses: a name, the model already
54/// selected, and Create.
55async function create(p, name) {
56 await p.evaluate(() => document.getElementById('new-diamond-btn').click());
57 await p.waitForSelector('.dlg-card', { timeout: 8000 });
58 const r = await p.evaluate((nm) => {
59 const card = [...document.querySelectorAll('.dlg-card')].find(c => c.getClientRects().length);
60 if (!card) return 'no dialog';
61 const inp = card.querySelector('input.dlg-input');
62 if (!inp) return 'no name field';
63 inp.value = nm;
64 inp.dispatchEvent(new Event('input', { bubbles: true }));
65 const btn = card.querySelector('.dlg-ok');
66 if (!btn) return 'no create button';
67 btn.click();
68 return 'ok';
69 }, name);
70 await p.waitForTimeout(900);
71 return r;
72}
73
74/// Delete the named Diamond the way a person does: the cog in its corner, then
75/// Delete at the foot of the dialog. The closer cross it used to press is gone
76/// (phase C), and so is the confirm that followed — since the trash, deleting a
77/// Diamond is reversible and asks nothing, and what this file is about is that
78/// the change reaches the other window either way.
79async function remove(p, name) {
80 const found = await p.evaluate((nm) => {
81 const box = [...document.querySelectorAll('#diamond-list .diamond-box')]
82 .find(b => ((b.querySelector('.session-box-name') || {}).textContent || '').trim() === nm);
83 if (!box) return false;
84 const cog = box.querySelector('.tile-cog');
85 if (!cog) return false;
86 cog.click();
87 return true;
88 }, name);
89 if (!found) return 'not in the rail';
90 await p.waitForSelector('.tile-dlg-delete', { timeout: 8000 });
91 await p.evaluate(() => document.querySelector('.tile-dlg-delete').click());
92 await p.waitForTimeout(1600);
93 return 'ok';
94}
95
96/// A second window on the same profile, signed in and looking at the same account.
97async function secondWindow(s) {
98 const p2 = await s.browser.newPage();
99 p2.on('pageerror', e => s.errs.push('page2: ' + e.message));
100 await p2.goto(APP, { waitUntil: 'domcontentloaded' });
101 await signInAs({ page: p2 }, s.name);
102 await p2.waitForTimeout(600);
103 return p2;
104}
105
106// ── 1 & 2: two windows, one account ──────────────────────────────────
107{
108 const dir = scratch('pw', 'diamonds-cross-' + process.pid);
109 fs.rmSync(dir, { recursive: true, force: true });
110 const s = await open({ name: 'dtabs', profile: dir });
111 const p1 = s.page;
112 const p2 = await secondWindow(s);
113
114 const made = await create(p1, 'Beta');
115 check('a Diamond can be made in the first window', made === 'ok', made);
116 const own = await railUntil(p1, r => r.includes('Beta'));
117 check('the window that made it shows it', own.includes('Beta'), own.join(', ') || 'empty');
118
119 const other = await railUntil(p2, r => r.includes('Beta'));
120 check('the OTHER window shows it, without a reload', other.includes('Beta'),
121 other.join(', ') || 'empty');
122
123 // The second window must be able to act on it, not merely list it.
124 const gone = await remove(p2, 'Beta');
125 check('it can be deleted from the other window', gone === 'ok', gone);
126 const back = await railUntil(p1, r => !r.includes('Beta'));
127 check('the deletion reaches the first window', !back.includes('Beta'),
128 back.join(', ') || 'empty');
129
130 await shot(s, 'diamonds-crosstab');
131 await s.close();
132}
133
134// ── 3: a create selects what it made ─────────────────────────────────
135{
136 const dir = scratch('pw', 'diamonds-filter-' + process.pid);
137 fs.rmSync(dir, { recursive: true, force: true });
138 const s = await open({ name: 'dfilter', profile: dir });
139 const p = s.page;
140
141 await create(p, 'Alpha');
142 await railUntil(p, r => r.includes('Alpha'));
143
144 // The rail's text search was removed at the user's request, so the filter this
145 // block was written around no longer exists. The property it proved does -- a
146 // Diamond made while the rail is filtered must not land out of sight -- and it
147 // is now proved against the TAG filter, which is the only filter left, at the
148 // foot of dev/verify_tags.mjs where the tags already are.
149 await create(p, 'Zeta');
150 await railUntil(p, r => r.includes('Zeta'));
151
152 // The centre followed the create, which is what selecting it means.
153 const centre = await p.$eval('#current-session-name', e => e.textContent.trim());
154 check('the new Diamond is the one on screen', centre === 'Zeta', centre);
155
156 await shot(s, 'diamonds-filter');
157 await s.close();
158}
159
160// ── 4: a create the store will not read back must SAY so ─────────────
161//
162// `list_diamonds` skips a Diamond whose meta it cannot read, silently. When the
163// one it skips is the one just made, the create looks like a no-op — and a user
164// who thinks nothing happened makes it again. The skip is simulated at the
165// store boundary, which is the only place it can arise.
166{
167 const dir = scratch('pw', 'diamonds-ghost-' + process.pid);
168 fs.rmSync(dir, { recursive: true, force: true });
169 const s = await open({ name: 'dghost', profile: dir });
170 const p = s.page;
171
172 const patched = await p.evaluate(() => {
173 if (!window.DaimondApp || !DaimondApp.prototype.list_diamonds) return false;
174 const real = DaimondApp.prototype.list_diamonds;
175 DaimondApp.prototype.list_diamonds = function () {
176 const json = real.apply(this, arguments);
177 try {
178 return JSON.stringify(JSON.parse(json).filter(d => d.name !== 'Ghost'));
179 } catch (e) { return json; }
180 };
181 window.__restoreList = () => { DaimondApp.prototype.list_diamonds = real; };
182 return true;
183 });
184 if (!patched) {
185 // Not `SKIPPED:` at the start of a line — run_all reads that as the whole
186 // verifier having skipped, which would hide the checks that did run.
187 skipped.push('the unreadable-read-back case: DaimondApp is an ES module import, '
188 + 'not a global, so the store cannot be faulted from the page. Proven instead by '
189 + 'injecting the fault in source (see the report).');
190 console.log(' skip the unreadable-read-back case — the store class is not reachable '
191 + 'from the page');
192 } else {
193 await create(p, 'Ghost');
194 await p.waitForTimeout(800);
195 const said = await p.evaluate(() => {
196 const card = [...document.querySelectorAll('.dlg-card')].find(c => c.getClientRects().length);
197 return card ? card.textContent : '';
198 });
199 check('a Diamond that will not read back is reported, not swallowed',
200 /Ghost/.test(said) && /read it back|not readable/i.test(said),
201 JSON.stringify(said.slice(0, 120)));
202 await shot(s, 'diamonds-ghost');
203 await p.evaluate(() => window.__restoreList && window.__restoreList());
204 }
205 console.log('\nconsole errors:', s.errs.filter(e => !/favicon|404|502/.test(e)).slice(0, 5));
206 await s.close();
207}
208
209if (skipped.length) console.log('\nnot covered here:\n ' + skipped.join('\n '));
210console.log(`\n${ok.length} passed, ${bad.length} failed, ${skipped.length} not covered`);
211if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
212process.exit(bad.length ? 1 : 0);