Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_devices.mjs

29.2 KiB, 1 run

created by r2519314175:357, 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_devices.mjs — "is my account on more than one device?", answered.
2//
3// Pairing moves the identity WHOLE, so both devices then hold one keypair and
4// the gateway cannot tell them apart; it keeps no record of a pairing either.
5// What can still be shown is a ROSTER: each device writes its own line, the
6// sync parcel carries the lines, and the merge unions them. This drives the
7// real page and checks the four things that make that roster trustworthy:
8//
9// 1. The id is minted, not measured. Two fresh installs of the same browser
10// on the same machine get DIFFERENT ids, and the id is only ever a random
11// number in this account's own storage.
12// 2. The parcel is byte-stable between real changes. This device's own stamp
13// does not move on every collect -- sync skips a push whose parcel
14// stringifies to what it last sent, and a stamp that always moved would
15// put the whole parcel back on the wire for nothing, for ever.
16// 3. The merge is freshest-wins, STRICTLY, per entry, with unknown entries
17// unioned in -- and a parcel with no roster at all is a no-op both ways.
18// 3b. The name a USER gives a device merges on a stamp of its own. A device
19// refreshes only its own `seen`, so a name typed on device A for device B
20// would never travel if it rode on `seen` -- B's next refresh would win the
21// whole line back and take the name off it.
22// 4. The surface tells the truth: it lists devices that SYNC this account, it
23// marks this one, it lets any line be named, and it offers no control that
24// pretends a device can be signed out (nothing could enforce that under one
25// shared keypair).
26// 5. A name typed at pairing time survives the reload that redeeming does, and
27// the new device's line takes it up the moment that line is minted.
28//
29// node dev/verify_devices.mjs
30//
31// Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs
32// (DAIMOND_MOCK_PORT, default 9099). No gateway.
33import fs from 'node:fs';
34import { open, shot, scratch } from './harness.mjs';
35
36// Two FIXED profiles, wiped before use rather than minted per run. Every check
37// below is about a FRESH install -- one that has never seen a roster -- so the
38// profile has to be new anyway, and a fixed pair leaves two directories behind
39// instead of two more on every run.
40const PROFILES = ['devices-one', 'devices-two'].map(n => scratch('pw', n));
41for (const p of PROFILES) fs.rmSync(p, { recursive: true, force: true });
42
43const ok = [], bad = [];
44const check = (name, pass, detail) => {
45 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
46 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
47};
48
49const A = 'aaaa1111bbbb2222'; // a fabricated second device
50const B = 'cccc3333dddd4444'; // and a third
51
52const s = await open({ name: 'devices', profile: PROFILES[0] });
53const { page } = s;
54
55try {
56 await page.waitForFunction(() => !!(window.DaimondCore && DaimondCore.collectSync), null,
57 { timeout: 12000 }).catch(() => {});
58
59 // ── (1) Minted, not measured ────────────────────────────────────
60 const mine = await page.evaluate(async () => {
61 const p = await DaimondCore.collectSync();
62 return {
63 devices: p.devices,
64 id: localStorage.getItem('daimond-device-id'),
65 stored: localStorage.getItem('daimond-devices'),
66 };
67 });
68 const ids = Object.keys(mine.devices || {});
69 check('the parcel carries a devices roster', !!mine.devices && typeof mine.devices === 'object',
70 JSON.stringify(mine.devices || null).slice(0, 120));
71 check('a fresh install knows exactly one device — itself', ids.length === 1, ids.join(','));
72 check('this device\'s id is 16 hex characters, kept in localStorage',
73 /^[0-9a-f]{16}$/.test(mine.id || ''), String(mine.id));
74 check('the roster is keyed by that id', ids[0] === mine.id, ids[0] + ' vs ' + mine.id);
75 const me = (mine.devices || {})[ids[0]] || {};
76 check('its line names the environment, in words', typeof me.name === 'string' && me.name.length > 2,
77 JSON.stringify(me.name));
78 check('with created and seen as real ms stamps (not 32-bit truncated)',
79 me.created > 1.7e12 && me.seen > 1.7e12, me.created + ' / ' + me.seen);
80 check('the merged roster is persisted', !!mine.stored && !!JSON.parse(mine.stored)[mine.id]);
81
82 // A second install of the SAME browser on the SAME machine. If the id were
83 // derived from the environment the two would collide; a random number cannot.
84 const s2 = await open({ name: 'devices2', profile: PROFILES[1] });
85 const other = await s2.page.evaluate(async () => {
86 const p = await DaimondCore.collectSync();
87 return { id: localStorage.getItem('daimond-device-id'), devices: p.devices };
88 }).catch(() => null);
89 await s2.close();
90 check('a second install of the same browser mints a DIFFERENT id — nothing is derived from the machine',
91 !!other && /^[0-9a-f]{16}$/.test(other.id) && other.id !== mine.id,
92 (other && other.id) + ' vs ' + mine.id);
93 const otherName = other && ((other.devices || {})[other.id] || {}).name;
94 check('but describes itself the same way, because the description IS the environment',
95 typeof otherName === 'string' && otherName.length > 2 && otherName === me.name,
96 otherName + ' vs ' + me.name);
97
98 // ── (2) Byte-stable between real changes ────────────────────────
99 const stable = await page.evaluate(async () => {
100 const a = JSON.stringify((await DaimondCore.collectSync()).devices);
101 await new Promise(r => setTimeout(r, 400));
102 const b = JSON.stringify((await DaimondCore.collectSync()).devices);
103 return { a, b };
104 });
105 check('two collects in a row stringify identically — the push skip survives',
106 typeof stable.a === 'string' && stable.a !== '{}' && stable.a === stable.b,
107 String(stable.a).slice(0, 80) + ' | ' + String(stable.b).slice(0, 80));
108
109 // ── (3) The merge ───────────────────────────────────────────────
110 const merged = await page.evaluate(async ([A, B]) => {
111 const now = Date.now();
112 const roster = () => JSON.parse(localStorage.getItem('daimond-devices') || '{}');
113 const out = {};
114 // An unknown device unions in.
115 await DaimondCore.applySync({ v: 2, devices: {
116 [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 3600e3 },
117 } });
118 out.unioned = roster();
119 // A STALE copy of a device already known must not roll it back.
120 await DaimondCore.applySync({ v: 2, devices: {
121 [A]: { name: 'Stale name', created: now - 9e6, seen: now - 7200e3 },
122 } });
123 out.afterStale = roster();
124 // An EQUAL stamp keeps what is here (strictly newer, or nothing happens).
125 await DaimondCore.applySync({ v: 2, devices: {
126 [A]: { name: 'Equal name', created: now - 9e6, seen: now - 3600e3 },
127 } });
128 out.afterEqual = roster();
129 // A fresher one wins.
130 await DaimondCore.applySync({ v: 2, devices: {
131 [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 60e3 },
132 } });
133 out.afterFresh = roster();
134 // This device's own line is not rolled back by a stale remote copy of it.
135 const self = localStorage.getItem('daimond-device-id');
136 const before = roster()[self];
137 await DaimondCore.applySync({ v: 2, devices: {
138 [self]: { name: 'Somebody else\'s idea', created: 1, seen: 1 },
139 } });
140 out.selfBefore = before; out.selfAfter = roster()[self];
141 // A malformed id is refused, so the roster stays a roster.
142 await DaimondCore.applySync({ v: 2, devices: {
143 '../../etc': { name: 'nope', created: now, seen: now },
144 '2': { name: 'nope', created: now, seen: now },
145 } });
146 out.afterJunk = roster();
147 // A third device, so the ordering below has something to order.
148 await DaimondCore.applySync({ v: 2, devices: {
149 [B]: { name: 'Safari on iOS', created: now - 5e6, seen: now - 86400e3 },
150 } });
151 out.three = roster();
152 out.parcel = (await DaimondCore.collectSync()).devices || {};
153 out.parcelKeys = Object.keys(out.parcel);
154 // And a parcel from a device that predates all of this changes nothing.
155 const snap = JSON.stringify(roster());
156 await DaimondCore.applySync({ v: 1 });
157 out.afterV1 = JSON.stringify(roster());
158 out.snap = snap;
159 out.self = self;
160 return out;
161 }, [A, B]);
162
163 check('an unknown device unions in', !!merged.unioned[A], Object.keys(merged.unioned).join(','));
164 check('a STALE copy of a known device does not roll it back',
165 merged.afterStale[A] && merged.afterStale[A].name === 'Firefox on Windows',
166 JSON.stringify(merged.afterStale[A]));
167 check('an EQUAL stamp keeps what is here — strictly newer, or nothing',
168 merged.afterEqual[A] && merged.afterEqual[A].name === 'Firefox on Windows',
169 JSON.stringify(merged.afterEqual[A]));
170 check('a FRESHER copy wins', merged.afterFresh[A] && merged.afterFresh[A].seen > merged.afterStale[A].seen,
171 JSON.stringify(merged.afterFresh[A]));
172 check('this device\'s own line is not rolled back by a stale remote copy of it',
173 merged.selfAfter && merged.selfAfter.seen === merged.selfBefore.seen,
174 JSON.stringify(merged.selfAfter));
175 check('a malformed device id is refused', !merged.afterJunk['../../etc'] && !merged.afterJunk['2'],
176 Object.keys(merged.afterJunk).join(','));
177 check('the parcel lists the ids SORTED — enumeration order would push for ever',
178 JSON.stringify(merged.parcelKeys) === JSON.stringify(merged.parcelKeys.slice().sort()),
179 merged.parcelKeys.join(','));
180 check('a parcel with no roster at all is a no-op', merged.afterV1 === merged.snap);
181
182 // ── (3b) The name the user gives a device ───────────────────────
183 // "Chromium on Linux" is what a device can say about itself; it is not what
184 // its owner calls it. So a line carries a LABEL as well, with a stamp of its
185 // own, and the rename is done through the real drawer control rather than by
186 // writing storage -- the point of the feature is that a user can do it.
187 // A roster written by a version that had no names at all, read back. It has
188 // to decode rather than be dropped, and it must not invent a name for a
189 // device nobody has named.
190 const legacy = await page.evaluate(async () => {
191 const keep = localStorage.getItem('daimond-devices') || '{}';
192 const OLD = 'eeee5555ffff6666';
193 localStorage.setItem('daimond-devices', JSON.stringify({
194 [OLD]: { name: 'Edge on Windows', created: 1.75e12, seen: 1.75e12 },
195 }));
196 const line = ((await DaimondCore.collectSync()).devices || {})[OLD];
197 localStorage.setItem('daimond-devices', keep);
198 return line;
199 });
200 check('a roster stored before names existed still decodes, with no name and no stamp',
201 !!legacy && legacy.name === 'Edge on Windows' && legacy.label === '' && legacy.namedAt === 0,
202 JSON.stringify(legacy));
203
204 const named = await page.evaluate(async ([A, B]) => {
205 const now = Date.now();
206 const roster = () => JSON.parse(localStorage.getItem('daimond-devices') || '{}');
207 const wait = (n) => new Promise(r => setTimeout(r, n));
208 // Rename a device the way a user does: open the drawer, press the control
209 // on that device's row, type, save.
210 const renameVia = async (id, text) => {
211 DaimondAdmin.home();
212 const row = [...document.querySelectorAll('#admin-home .device-row')]
213 .find(r => ((r.querySelector('.device-id') || {}).textContent || '') === id.slice(-4));
214 const btn = row && row.querySelector('.device-rename');
215 if (!btn) return false;
216 btn.click();
217 await wait(80);
218 const input = document.querySelector('.dlg .dlg-input');
219 const ok = document.querySelector('.dlg .dlg-ok');
220 if (!input || !ok) return false;
221 input.value = text;
222 ok.click();
223 await wait(200);
224 return !document.querySelector('.dlg');
225 };
226 const out = {};
227 // This device names ANOTHER device's line.
228 out.renamed = await renameVia(A, ' Kitchen laptop ');
229 out.local = roster()[A];
230 out.parcel = ((await DaimondCore.collectSync()).devices || {})[A];
231 // The named device refreshes its own `seen` and knows nothing of the name.
232 // Its line is fresher, so it wins the line -- and must not take the name.
233 await DaimondCore.applySync({ v: 2, devices: {
234 [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 1e3 },
235 } });
236 out.afterSelfRefresh = roster()[A];
237 // A rename made on the OTHER device arrives even though its `seen` is
238 // older than what is here: the label merges on its own stamp alone.
239 await DaimondCore.applySync({ v: 2, devices: {
240 [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 9e5,
241 label: 'Work desktop', namedAt: now + 5e3 },
242 } });
243 out.afterRemoteRename = roster()[A];
244 // An EQUAL namedAt keeps what is here, exactly as an equal `seen` does.
245 await DaimondCore.applySync({ v: 2, devices: {
246 [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 9e5,
247 label: 'Somebody else\'s idea', namedAt: now + 5e3 },
248 } });
249 out.afterEqualNamedAt = roster()[A];
250 // And an OLDER rename loses.
251 await DaimondCore.applySync({ v: 2, devices: {
252 [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 9e5,
253 label: 'Older idea', namedAt: now - 1e6 },
254 } });
255 out.afterOlderRename = roster()[A];
256 // A device that predates names carries neither field, and must not take
257 // the name off a line that has one -- even when its `seen` wins the line.
258 await DaimondCore.applySync({ v: 2, devices: {
259 [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 500 },
260 } });
261 out.afterOldFormat = roster()[A];
262 // Clearing a name is itself a rename: an empty label with a fresher stamp,
263 // arriving on a line whose `seen` did not move at all.
264 // Its `seen` is EXACTLY what is already stored, so nothing but the naming
265 // can move: a label that travelled with the entry would not arrive at all.
266 const bSeen = (roster()[B] || {}).seen;
267 await DaimondCore.applySync({ v: 2, devices: {
268 [B]: { name: 'Safari on iOS', created: now - 5e6, seen: bSeen,
269 label: 'Phone', namedAt: now },
270 } });
271 out.bNamed = roster()[B];
272 await DaimondCore.applySync({ v: 2, devices: {
273 [B]: { name: 'Safari on iOS', created: now - 5e6, seen: bSeen,
274 label: '', namedAt: now + 2e3 },
275 } });
276 out.bCleared = roster()[B];
277 // A name is a name, not a paragraph.
278 await renameVia(B, 'x'.repeat(400));
279 out.capped = ((roster()[B] || {}).label || '').length;
280 await renameVia(B, '');
281 out.bBlank = roster()[B];
282 // The push skip survives the two new fields.
283 const s1 = JSON.stringify((await DaimondCore.collectSync()).devices);
284 await wait(300);
285 const s2 = JSON.stringify((await DaimondCore.collectSync()).devices);
286 out.stableA = s1; out.stableB = s2;
287 return out;
288 }, [A, B]);
289
290 check('a device row can be renamed from the drawer', named.renamed === true, String(named.renamed));
291 check('the name is stored trimmed, with a stamp of its own',
292 !!named.local && named.local.label === 'Kitchen laptop' && named.local.namedAt > 1.7e12,
293 JSON.stringify(named.local));
294 check('the derived description is kept underneath it, as the fallback',
295 !!named.local && named.local.name === 'Firefox on Windows', JSON.stringify(named.local));
296 check('and the name rides in the parcel, so it can reach the other devices',
297 !!named.parcel && named.parcel.label === 'Kitchen laptop' && named.parcel.namedAt === named.local.namedAt,
298 JSON.stringify(named.parcel));
299 check('the named device refreshing its own seen does NOT take the name off',
300 !!named.afterSelfRefresh && named.afterSelfRefresh.label === 'Kitchen laptop'
301 && named.afterSelfRefresh.seen > named.local.seen,
302 JSON.stringify(named.afterSelfRefresh));
303 check('a rename from another device arrives even though its seen is older — the label merges on namedAt',
304 !!named.afterRemoteRename && named.afterRemoteRename.label === 'Work desktop',
305 JSON.stringify(named.afterRemoteRename));
306 check('an EQUAL namedAt keeps the name that is here — strictly newer, or nothing',
307 !!named.afterEqualNamedAt && named.afterEqualNamedAt.label === 'Work desktop',
308 JSON.stringify(named.afterEqualNamedAt));
309 check('an OLDER rename loses', !!named.afterOlderRename && named.afterOlderRename.label === 'Work desktop',
310 JSON.stringify(named.afterOlderRename));
311 check('an entry from before names existed carries none, and takes none away',
312 !!named.afterOldFormat && named.afterOldFormat.label === 'Work desktop'
313 && named.afterOldFormat.seen > named.afterRemoteRename.seen,
314 JSON.stringify(named.afterOldFormat));
315 check('a name reaches a line whose seen did not move at all',
316 !!named.bNamed && named.bNamed.label === 'Phone', JSON.stringify(named.bNamed));
317 check('and clearing it is itself a rename, so the clearing travels too',
318 !!named.bCleared && named.bCleared.label === '' && named.bCleared.namedAt > named.bNamed.namedAt,
319 JSON.stringify(named.bCleared));
320 check('a name is capped, so one line cannot become a paragraph', named.capped === 64,
321 String(named.capped));
322 check('an empty box clears the name, back to what the device says about itself',
323 !!named.bBlank && named.bBlank.label === '' && named.bBlank.name === 'Safari on iOS',
324 JSON.stringify(named.bBlank));
325 check('two collects in a row still stringify identically with names in play — the push skip survives',
326 typeof named.stableA === 'string' && named.stableA === named.stableB,
327 String(named.stableA).slice(0, 100));
328
329 // ── (4) The surface ─────────────────────────────────────────────
330 const view = await page.evaluate(() => {
331 DaimondAdmin.home();
332 const secs = [...document.querySelectorAll('#admin-home .admin-sec')].map(e => e.textContent);
333 const rows = [...document.querySelectorAll('#admin-home .device-row')].map(r => ({
334 text: r.innerText.replace(/\s+/g, ' ').trim(),
335 name: (r.querySelector('.device-name') || {}).textContent || '',
336 nameTitle: (r.querySelector('.device-name') || {}).title,
337 nameLabel: (r.querySelector('.device-name') || {}).getAttribute
338 ? r.querySelector('.device-name').getAttribute('aria-label') : null,
339 suffix: (r.querySelector('.device-id') || {}).textContent || '',
340 buttons: r.querySelectorAll('button').length,
341 // What each control CLAIMS to do, which is the thing worth asserting on.
342 // A count cannot tell a rename from a revoke, and counting is what made
343 // this file go stale twice as controls were added beside it.
344 acts: [...r.querySelectorAll('button')].map(b =>
345 ((b.getAttribute('aria-label') || b.title || b.textContent || '').trim())),
346 rename: r.querySelectorAll('button.device-rename').length,
347 }));
348 const notes = [...document.querySelectorAll('#admin-home .admin-note')].map(e => e.textContent);
349 return { secs, rows, notes, self: localStorage.getItem('daimond-device-id') };
350 });
351 check('the Admin drawer has a Devices section', view.secs.some(x => /device/i.test(x)),
352 view.secs.join(' | '));
353 check('with one line per device', view.rows.length === 3, view.rows.length + ' rows');
354 check('exactly one of them is marked as this device',
355 view.rows.filter(r => /this device/i.test(r.text)).length === 1,
356 view.rows.map(r => r.text).join(' | '));
357 check('the others carry a relative last-seen, not a raw stamp',
358 view.rows.filter(r => /(just now|\d+[mhd] ago)/.test(r.text)).length === 2,
359 view.rows.map(r => r.text).join(' | '));
360 check('every row offers a rename — a device is named where it is listed',
361 view.rows.every(r => r.rename === 1), view.rows.map(r => r.rename).join(','));
362 // The property, stated as a property. Removing a line from this list is not a
363 // revocation and must never read as one: every paired device holds the SAME
364 // keypair (`identity.js` exportBundle hands over the wrapped private key), so
365 // no control here could revoke one even if it said it did.
366 //
367 // This was `buttons === 1` and went stale the moment a second control landed
368 // beside the rename, which is the ninth time a literal count has broken a
369 // check in this codebase. A count cannot tell a rename from a revoke; the
370 // words on the controls can.
371 const REVOKES = /revoke|sign ?out|log ?out|disconnect|unpair|deauthor/i;
372 const claims = view.rows.flatMap(r => r.acts).filter(a => REVOKES.test(a));
373 check('nothing here pretends a device can be revoked',
374 claims.length === 0, claims.join(' | ') || view.rows.flatMap(r => r.acts).join(' | '));
375 check('a named device shows the user\'s name in place of the derived description',
376 view.rows.some(r => r.name === 'Work desktop') && !view.rows.some(r => r.name === 'Firefox on Windows'),
377 view.rows.map(r => r.name).join(' | '));
378 check('a device with no name of its own still shows what it says about itself',
379 view.rows.some(r => r.name === 'Safari on iOS'), view.rows.map(r => r.name).join(' | '));
380 // THE PROPERTY. This list is what has SYNCED, and the note has to say so in
381 // three parts, because every row above it carries a ✕: syncing is what puts
382 // a device here, a device that holds the account without syncing is
383 // therefore MISSING from it (so absence is not proof of no access), and
384 // taking a line off the list does not sign that device out.
385 //
386 // This was `/sync/ && /appears/` and went red when the copy said "is not
387 // listed" instead of "never appears here" -- the same claim in other words.
388 // `/sync/` alone would pass for a note that called these paired devices and
389 // left a user believing the ✕ revoked one.
390 const noteIsHonest = (n) => {
391 const bits = n.split(/(?<=[.!?:;])\s+/);
392 const neg = /\b(not|no|never|nothing|none|cannot)\b|n[’']t\b/i;
393 const list = /\b(list|listed|listing|appears?|shown?|show|here|below|missing)\b/i;
394 return {
395 // Syncing is what puts a device on this list.
396 scope: /sync/i.test(n),
397 // One that holds the account and has not synced is absent from it.
398 absent: bits.some(b => /sync/i.test(b) && neg.test(b) && list.test(b)),
399 // And nothing on this list ends a device's access.
400 signout: bits.some(b => neg.test(b)
401 && /\bsigns?[- ]?(a |the )?(device |it )?out\b|\brevokes?\b|\bcuts? off\b|\bdeauthor/i.test(b)),
402 };
403 };
404 const honest = view.notes.map(noteIsHonest)
405 .find(h => h.scope && h.absent && h.signout);
406 check('and the copy says these are devices that SYNC, that one which has not is missing, and that nothing here signs a device out',
407 !!honest,
408 view.notes.filter(n => /sync|device/i.test(n)).join(' | ').slice(0, 200));
409 // Two of a user's devices can easily describe themselves identically
410 // ("Chrome on macOS" twice), so each line carries the tail of its own id.
411 const wantSuffix = [view.self, A, B].map(x => x.slice(-4)).sort();
412 check('each line carries the tail of its OWN id, so two alike devices are still two',
413 JSON.stringify(view.rows.map(r => r.suffix).sort()) === JSON.stringify(wantSuffix),
414 view.rows.map(r => r.suffix).join(',') + ' vs ' + wantSuffix.join(','));
415
416 // notes4.txt, Admin panel: "The Device names are shortened with '...' which
417 // is fine but they should show hover text with the full name." CSS does the
418 // shortening (`.device-name{text-overflow:ellipsis}`); what is asked here is
419 // that the FULL name still reaches a mouse (`title`) and a keyboard or screen
420 // reader user, for whom a `title` is invisible (`aria-label`).
421 check('every device name carries the FULL name in a title, for a mouse to hover',
422 view.rows.every(r => r.nameTitle === r.name && r.name.length > 0),
423 JSON.stringify(view.rows.map(r => ({ name: r.name, title: r.nameTitle }))));
424 check('and in an aria-label, since a title alone says nothing to a keyboard or screen-reader user',
425 view.rows.every(r => r.nameLabel === r.name),
426 JSON.stringify(view.rows.map(r => ({ name: r.name, label: r.nameLabel }))));
427
428 // This device can be named too, and naming it must not cost it the one mark
429 // that says which line the user is standing on.
430 const selfNamed = await page.evaluate(async () => {
431 const self = localStorage.getItem('daimond-device-id');
432 DaimondAdmin.home();
433 const row = [...document.querySelectorAll('#admin-home .device-row')]
434 .find(r => ((r.querySelector('.device-id') || {}).textContent || '') === self.slice(-4));
435 const btn = row && row.querySelector('.device-rename');
436 if (!btn) return null;
437 btn.click();
438 await new Promise(r => setTimeout(r, 80));
439 const input = document.querySelector('.dlg .dlg-input');
440 const ok = document.querySelector('.dlg .dlg-ok');
441 if (!input || !ok) return null;
442 // The box opens on the name that is there now, and says what it falls back
443 // to when emptied.
444 const opened = { value: input.value, placeholder: input.placeholder || '' };
445 input.value = 'Studio Mac';
446 ok.click();
447 await new Promise(r => setTimeout(r, 200));
448 const mine = [...document.querySelectorAll('#admin-home .device-row')]
449 .find(r => ((r.querySelector('.device-id') || {}).textContent || '') === self.slice(-4));
450 return {
451 opened: opened,
452 name: (mine.querySelector('.device-name') || {}).textContent || '',
453 text: mine.innerText.replace(/\s+/g, ' ').trim(),
454 suffix: (mine.querySelector('.device-id') || {}).textContent || '',
455 };
456 });
457 check('this device can be named as well', !!selfNamed && selfNamed.name === 'Studio Mac',
458 JSON.stringify(selfNamed));
459 check('and stays marked as this device, with its id tail, once named',
460 !!selfNamed && /this device/i.test(selfNamed.text) && selfNamed.suffix.length === 4,
461 selfNamed && selfNamed.text);
462 check('the rename box offers what the device calls itself as the placeholder',
463 !!selfNamed && /\w/.test(selfNamed.opened.placeholder) && selfNamed.opened.value === '',
464 JSON.stringify(selfNamed && selfNamed.opened));
465
466 await shot(s, 'devices-roster');
467
468 // Back to one device: the quiet line has to answer the question in the other
469 // direction too, or a user with one device learns nothing at all.
470 const alone = await page.evaluate(() => {
471 const self = localStorage.getItem('daimond-device-id');
472 const keep = JSON.parse(localStorage.getItem('daimond-devices') || '{}')[self];
473 localStorage.setItem('daimond-devices', JSON.stringify({ [self]: keep }));
474 DaimondAdmin.home();
475 return {
476 rows: [...document.querySelectorAll('#admin-home .device-row')].length,
477 notes: [...document.querySelectorAll('#admin-home .admin-note')].map(e => e.textContent),
478 };
479 });
480 check('with one device there is still a line for it', alone.rows === 1, String(alone.rows));
481 check('and a sentence saying so, so the question is answered either way',
482 alone.notes.some(n => /only this device/i.test(n)),
483 alone.notes.join(' | ').slice(0, 120));
484
485 // ── (5) A name chosen while pairing ─────────────────────────────
486 // The name is typed on the NEW device, during redeem — before that device has
487 // a line to put it on. Its line is minted on the first collect, which happens
488 // after the reload the redeem dialog performs, so the name is stashed and the
489 // mint consumes it. Redeeming needs a gateway; the field and the stash do not.
490 const paired = await page.evaluate(async () => {
491 const out = {};
492 DaimondPairing.showRedeem('ABCD1234');
493 await new Promise(r => setTimeout(r, 80));
494 const box = document.querySelector('.pair-scrim .pair-box');
495 const field = box && box.querySelector('.pair-name');
496 out.hasField = !!field;
497 out.ph = field ? (field.getAttribute('placeholder') || '') : '';
498 out.maxlen = field ? (field.getAttribute('maxlength') || '') : '';
499 document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
500 await new Promise(r => setTimeout(r, 60));
501 out.closed = !document.querySelector('.pair-scrim');
502 // What a successful redeem does with what was typed.
503 DaimondPairing.stashName(' Kitchen laptop ');
504 out.stash = localStorage.getItem('daimond-pair-label');
505 // The reload boundary: a device that has just redeemed has no line at all.
506 localStorage.removeItem('daimond-device-id');
507 localStorage.setItem('daimond-devices', '{}');
508 const reg = (await DaimondCore.collectSync()).devices || {};
509 out.self = reg[localStorage.getItem('daimond-device-id')] || null;
510 out.stashAfter = localStorage.getItem('daimond-pair-label');
511 // And the next device to mint a line does not inherit it.
512 localStorage.removeItem('daimond-device-id');
513 localStorage.setItem('daimond-devices', '{}');
514 const reg2 = (await DaimondCore.collectSync()).devices || {};
515 out.second = reg2[localStorage.getItem('daimond-device-id')] || null;
516 return out;
517 });
518 check('the redeem dialog offers a name for the device being linked',
519 paired.hasField === true && /\w/.test(paired.ph), JSON.stringify(paired.ph));
520 check('the field is capped there too', String(paired.maxlen) === '64', String(paired.maxlen));
521 check('the chosen name is stashed, trimmed, across the reload redeeming does',
522 paired.stash === 'Kitchen laptop', JSON.stringify(paired.stash));
523 check('and the roster takes it up the moment this device first mints its line',
524 !!paired.self && paired.self.label === 'Kitchen laptop' && paired.self.namedAt > 1.7e12,
525 JSON.stringify(paired.self));
526 check('the stash is consumed, so the next line minted is not named for it',
527 !paired.stashAfter && !!paired.second && paired.second.label === '',
528 JSON.stringify(paired.stashAfter) + ' / ' + JSON.stringify(paired.second));
529
530 const errs = s.errs.filter(e => !/favicon|ERR_|Failed to load resource|401|402|409|426|502/.test(e));
531 check('no unexpected console errors', errs.length === 0, errs.slice(0, 3).join(' | '));
532} catch (e) {
533 check('verify_devices ran without throwing', false, String(e && e.message || e));
534} finally {
535 await s.close?.().catch?.(() => {});
536}
537
538console.log('\n' + (bad.length ? `FAIL: ${bad.length} failed, ${ok.length} passed` : `ok: all ${ok.length} passed`));
539process.exit(bad.length ? 1 : 0);