Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_fsa.mjs

24.5 KiB, 1 run

created by r2519314175:427, 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_fsa.mjs — real-folder mode: the root swap, and the state that must not follow it.
2//
3// FSA lets the owner point Daimond at a real directory, so the agents edit actual files instead
4// of the OPFS sandbox. It has never been driven, because showDirectoryPicker() opens a native
5// dialog: Chrome routes it through Page.setInterceptFileChooserDialog, which Playwright enables
6// and cannot answer for a *directory*, so the call aborts. That blocks the picker -- it does not
7// block the feature.
8//
9// The insight this test rests on: what the picker returns is a FileSystemDirectoryHandle, and OPFS
10// hands out the very same type. An OPFS subdirectory handle is a directory handle, answers
11// queryPermission with 'granted', and structured-clones into IndexedDB -- so it can stand in for a
12// picked folder everywhere the code touches one. What is NOT covered is the one thing only a real
13// folder can show: that the bytes land on the user's actual disk. Everything up to that boundary
14// is covered here.
15//
16// The claim that matters most is the last one. Real-folder mode points the *file tools* at the
17// user's directory, and Daimond's own state -- the Diamonds, their logs, the .daimond store -- pins
18// OPFS on purpose (FileRoot::Opfs, src/wasm/opfs.rs). If that pin ever slipped, opening a folder
19// would strew the app's internals through the user's repository. That is the test that earns its
20// keep.
21import { open, signInAs, shot } from './harness.mjs';
22
23const ok = [], bad = [];
24const check = (name, pass, detail) => {
25 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
26 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
27};
28
29const FOLDER = 'realfolder'; // an OPFS subdirectory standing in for a picked folder
30
31const s = await open({ name: 'fsa', connect: false });
32const p = s.page;
33await p.waitForTimeout(1500);
34
35// ── 0. Where the switch lives ───────────────────────────────────────────
36//
37// Opening a folder is not a file operation. It used to be an icon in the Workspace header,
38// between New file, New folder and Upload — the only control there that acts on the workspace
39// rather than on a file, which read as "make a folder" and left the mode chip looking like a
40// label with no switch. It belongs beside the chip that says which files the agent is touching.
41
42// The switch is now the Machine CHIP itself: a chip states where things are, and
43// clicking the one that is not current is what moves the agent there. So what has
44// to be true is that the row carries an actionable Machine chip -- not that it
45// carries a button reading "Open a folder…", which is what it was before the
46// chips took over.
47const where = await p.evaluate(() => {
48 const row = document.querySelector('.files-mode');
49 const header = document.querySelector('.files-actions');
50 const btns = [...(row ? row.querySelectorAll('.files-mode-btn') : [])].map(b => b.textContent);
51 const chips = [...(row ? row.querySelectorAll('.files-mode-chip') : [])];
52 const machine = chips.find(c => /Machine|💻/.test(c.textContent));
53 return {
54 inRow: !!(machine && machine.classList.contains('act')),
55 inHeader: !!(header && header.querySelector('[data-act="open-folder"]')),
56 chips: chips.map(c => c.textContent.trim()).join(' | '),
57 buttons: btns,
58 };
59});
60check('the switch to a real folder sits in the mode row, as the Machine chip',
61 where.inRow === true, `${where.chips} · ${where.buttons.join(' | ')}`);
62check('and no longer hides among the file buttons in the header',
63 where.inHeader === false);
64
65// ── A. The root swap, at the wasm edge ──────────────────────────────────
66
67const swap = await p.evaluate(async ({ folder }) => {
68 const mod = await import('../pkg/oxedyne_daimond.js');
69 const root = await navigator.storage.getDirectory();
70 const dir = await root.getDirectoryHandle(folder, { create: true });
71
72 const before = mod.workspace_mode();
73 mod.set_workspace_dir(dir);
74 const after = mod.workspace_mode();
75
76 // Write through the agent's own file tool, which is the thing real-folder mode redirects.
77 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
78 const wrote = await app.run_tool('file_write',
79 JSON.stringify({ path: 'notes.md', content: 'written into the real folder' }));
80 const listed = await app.run_tool('file_list', JSON.stringify({ path: '.' }));
81 const read = await app.run_tool('file_read', JSON.stringify({ path: 'notes.md' }));
82
83 // Where did the bytes actually land? Ask OPFS directly, not the tool that wrote them.
84 const at = async (d, name) => {
85 try { await d.getFileHandle(name); return true; } catch (e) { return false; }
86 };
87 const inFolder = await at(dir, 'notes.md');
88 const atRoot = await at(root, 'notes.md');
89
90 return { before, after, wrote, listed, read, inFolder, atRoot };
91}, { folder: FOLDER });
92
93check('the workspace starts in the OPFS sandbox', swap.before === 'opfs', swap.before);
94check('opening a folder swaps the root', swap.after === 'folder', swap.after);
95check('a file tool writes into the folder, not the sandbox',
96 swap.inFolder === true && swap.atRoot === false,
97 `in folder: ${swap.inFolder}, at OPFS root: ${swap.atRoot}`);
98check('and reads it back through the folder', /written into the real folder/.test(swap.read || ''));
99check('and lists it there', /notes\.md/.test(swap.listed || ''));
100
101// ── B. The invariant: Daimond's own state must not follow the root ──────
102//
103// A Diamond keeps its crystal, its log and its deltas under FileRoot::Opfs. With a folder open, that
104// state must still be in the sandbox — if it followed the swap, opening a repository would write
105// Daimond's internals into it.
106
107const pinned = await p.evaluate(async ({ folder }) => {
108 const mod = await import('../pkg/oxedyne_daimond.js');
109 const root = await navigator.storage.getDirectory();
110 const dir = await root.getDirectoryHandle(folder, { create: true });
111
112 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
113 const id = await app.create_diamond('A Diamond made while a folder is open');
114
115 const has = async (d, name) => {
116 try { await d.getDirectoryHandle(name); return true; } catch (e) { return false; }
117 };
118 return {
119 id,
120 diamondsInSandbox: await has(root, 'diamonds'),
121 diamondsInFolder: await has(dir, 'diamonds'),
122 mode: mod.workspace_mode(),
123 listed: JSON.parse(await app.list_diamonds() || '[]').length,
124 };
125}, { folder: FOLDER });
126
127check('a Diamond made with a folder open still lives in the sandbox',
128 pinned.diamondsInSandbox === true, 'diamonds/ in OPFS: ' + pinned.diamondsInSandbox);
129check("Daimond's own state never lands in the user's folder",
130 pinned.diamondsInFolder === false, 'diamonds/ in the folder: ' + pinned.diamondsInFolder);
131check('and the Diamond is readable while the folder is open', pinned.listed >= 1,
132 pinned.listed + ' diamonds');
133
134// ── C. Switching back ───────────────────────────────────────────────────
135
136const back = await p.evaluate(async () => {
137 const mod = await import('../pkg/oxedyne_daimond.js');
138 mod.use_opfs_workspace();
139 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
140 // The folder's file is no longer in view: the tools are back on the sandbox root.
141 const read = await app.run_tool('file_read', JSON.stringify({ path: 'notes.md' }));
142 return { mode: mod.workspace_mode(), read };
143});
144check('switching back returns the tools to the sandbox', back.mode === 'opfs', back.mode);
145check("and the folder's files are out of view",
146 /^\s*Error\b/i.test(back.read || ''), (back.read || '').slice(0, 40));
147
148// ── D. Reconnect on boot, through the app's own path ────────────────────
149//
150// tryReconnect() runs at boot: it loads the stored handle, checks queryPermission, and reactivates
151// the folder. Seeding the handle where it looks drives that whole path -- FsaDB, activateFolder,
152// the mode indicator -- without a picker.
153
154await p.evaluate(async ({ folder }) => {
155 const root = await navigator.storage.getDirectory();
156 const dir = await root.getDirectoryHandle(folder, { create: true });
157 const db = await new Promise((res, rej) => {
158 const q = indexedDB.open('daimond-fsa', 1);
159 q.onupgradeneeded = () => q.result.createObjectStore('handles');
160 q.onsuccess = () => res(q.result);
161 q.onerror = () => rej(q.error);
162 });
163 await new Promise((res, rej) => {
164 const t = db.transaction('handles', 'readwrite');
165 t.objectStore('handles').put(dir, 'workspace');
166 t.oncomplete = res;
167 t.onerror = () => rej(t.error);
168 });
169}, { folder: FOLDER });
170
171await p.reload({ waitUntil: 'domcontentloaded' });
172await signInAs(s, 'fsa');
173await p.waitForTimeout(2500);
174
175/// The mode row carries THREE chips -- Browser, Machine and Cloud -- and the one
176/// that answers "where is the agent working" is whichever carries `active`.
177/// Reading the first chip in the row would always read "🗄 Browser" and say
178/// nothing about the folder.
179const chips = () => p.evaluate(() => {
180 const cs = [...document.querySelectorAll('.files-mode-chip')];
181 const on = cs.find(c => c.classList.contains('active'));
182 return {
183 active: on ? on.textContent.trim() : '(none)',
184 all: cs.map(c => c.textContent.trim()).join(' | '),
185 };
186});
187
188const reconnected = await p.evaluate(async () => {
189 const mod = await import('../pkg/oxedyne_daimond.js');
190 return { mode: mod.workspace_mode() };
191});
192reconnected.chips = await chips();
193check('the folder is reconnected on the next visit, with no prompt',
194 reconnected.mode === 'folder', reconnected.mode);
195check('and the panel says which folder the agent is touching',
196 reconnected.chips.active.includes(FOLDER), reconnected.chips.all);
197
198// ── D2. Opening a file in the folder shows it as what it is ─────────────
199//
200// Reported as "clicking a PDF in the Machine workspace displays it as raw text".
201// It was not the folder's doing -- the Doc panel routed on whether the leading
202// bytes decode as characters, which the front of a PDF with no binary comment
203// does, and it did that whichever root was open. But the folder is where a
204// person's real PDFs are, so it is where the bug was met, and this is the check
205// that says the root is not what decides. `dev/verify_fileview.mjs` owns the
206// routing itself and owns proving the page actually renders; this one owns
207// "and the same is true through a real folder".
208//
209// ASCII from end to end, and no binary comment: that is the failing class.
210const ASCII_PDF = '%PDF-1.4\n'
211 + '1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n'
212 + '2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n'
213 + '3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 99 99]>>endobj\n'
214 + 'trailer<</Root 1 0 R>>\n%%EOF\n';
215
216const pdfInFolder = await p.evaluate(async ({ folder, text }) => {
217 // Written through the FOLDER HANDLE, not through a file tool: the file is
218 // one the user already had, not one Daimond put there.
219 const root = await navigator.storage.getDirectory();
220 const dir = await root.getDirectoryHandle(folder, { create: true });
221 const fh = await dir.getFileHandle('paper.pdf', { create: true });
222 const w = await fh.createWritable();
223 await w.write(new TextEncoder().encode(text));
224 await w.close();
225 // The tree was drawn before that write, so it is relisted through the
226 // panel's own Refresh.
227 const r = document.querySelector('.files-actions [data-act="refresh"]');
228 if (r) r.click();
229 await new Promise((res) => setTimeout(res, 1200));
230 const row = document.querySelector('.files-tree .files-row[data-path="paper.pdf"]');
231 if (!row) return { listed: false };
232 row.click();
233 await new Promise((res) => setTimeout(res, 1800));
234 // The PREVIEW panel for the rendering, the DOC panel for the editor: a file
235 // that is not characters is drawn in one and must not reach the other.
236 const fv = document.querySelector('#pv-view .fileview');
237 const pre = document.querySelector('#doc-view .files-view-body');
238 return {
239 listed: true,
240 viewer: fv ? fv.getAttribute('data-viewer') : null,
241 embed: !!document.querySelector('#pv-view .fileview embed'),
242 pre: pre ? pre.textContent.slice(0, 60) : null,
243 };
244}, { folder: FOLDER, text: ASCII_PDF });
245
246check('a PDF in the open folder is listed where the user can click it',
247 pdfInFolder.listed === true, JSON.stringify(pdfInFolder));
248check('and clicking it shows the PDF, not its bytes as characters',
249 pdfInFolder.viewer === 'doc' && pdfInFolder.embed === true && pdfInFolder.pre === null,
250 JSON.stringify(pdfInFolder));
251
252// ── E. A grant that is taken away ───────────────────────────────────────
253//
254// The browser can withdraw a folder at any time, and every tool call that touches it then fails
255// while the app goes on naming a folder the agent cannot reach. Two things have to be true: an
256// ORDINARY failure must not tear the folder down, and a withdrawn grant must.
257
258// The negative case first, and it is the one that would do real damage if wrong: reading a file
259// that is not there fails, as it should, and the folder must survive it.
260const ordinary = await p.evaluate(async () => {
261 const mod = await import('../pkg/oxedyne_daimond.js');
262 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
263 const res = await app.run_tool('file_read', JSON.stringify({ path: 'no-such-file.md' }));
264 await new Promise(r => setTimeout(r, 300));
265 return { res: (res || '').slice(0, 30), mode: mod.workspace_mode() };
266});
267ordinary.chips = await chips();
268check('an ordinary tool error does not tear the folder down',
269 ordinary.mode === 'folder' && ordinary.chips.active.includes(FOLDER),
270 `mode: ${ordinary.mode}, chips: ${ordinary.chips.all}`);
271
272// And the positive: the edge raises `daimond:folder-lost`, and the app must drop to the sandbox
273// and say so rather than carry on against a folder it no longer has.
274const lost = await p.evaluate(async () => {
275 window.dispatchEvent(new CustomEvent('daimond:folder-lost'));
276 await new Promise(r => setTimeout(r, 600));
277 const mod = await import('../pkg/oxedyne_daimond.js');
278 const msg = document.querySelector('.files-mode-msg');
279 // The way back is the Machine chip itself, relabelled "💻 Reconnect <name>"
280 // by renderMode(lost) -- an offer that needs the user's gesture, never a
281 // prompt on load. It is not a separate button.
282 const back = [...document.querySelectorAll('.files-mode-chip')]
283 .some(c => /Reconnect/i.test(c.textContent));
284 return {
285 mode: mod.workspace_mode(),
286 msg: msg ? msg.textContent.trim() : '(none)',
287 reconnect: back,
288 };
289});
290lost.chips = await chips();
291check('a withdrawn grant drops the agent back to the sandbox',
292 lost.mode === 'opfs', lost.mode);
293// The row still NAMES the folder, in the reconnect offer -- what it must not do
294// is go on claiming the agent is working there, so it is the ACTIVE chip that
295// has to have moved back to the browser sandbox.
296check('and the panel stops claiming a folder it cannot reach',
297 !lost.chips.active.includes(FOLDER), lost.chips.all);
298check('and the user is told, and offered a way back',
299 /Lost access/i.test(lost.msg) && lost.reconnect === true,
300 `${lost.msg} · reconnect offered: ${lost.reconnect}`);
301
302// ── C2. Going to the sandbox and coming back ────────────────────────────
303//
304// The round trip a user actually makes: point the agent at a folder, send it back to the browser
305// sandbox for something that has to sync, then put it back on the folder. It cost a folder picker
306// EVERY time, because the Machine chip called `showDirectoryPicker` unconditionally and switching
307// to Browser had already deleted the stored handle -- so there was nothing left to go back to.
308// Two bugs producing one symptom, and the symptom is a native dialog no test could answer.
309//
310// The picker is stubbed with a counter that aborts. That is honest to what a picker does when the
311// user dismisses it, and it turns "was a dialog shown" -- otherwise unobservable -- into a number.
312// Nothing below asserts that a picker WORKS; what is asserted is when one is asked for.
313
314await p.evaluate(() => {
315 window.__pick = 0;
316 window.showDirectoryPicker = function (opts) {
317 window.__pick++;
318 window.__pickOpts = opts || null;
319 var e = new Error('The user aborted a request.');
320 e.name = 'AbortError'; // what Chrome throws on a dismissed picker
321 return Promise.reject(e);
322 };
323});
324
325const mode = () => p.evaluate(async () => (await import('../pkg/oxedyne_daimond.js')).workspace_mode());
326const picks = () => p.evaluate(() => window.__pick);
327const zero = () => p.evaluate(() => { window.__pick = 0; });
328const msg = () => p.evaluate(() => {
329 const m = document.querySelector('.files-mode-msg');
330 return m ? m.textContent.trim() : '(none)';
331});
332/// Click a chip in the mode row by its visible text. The Machine chip is named for the folder
333/// when there is one, so it is matched on either.
334const clickChip = (pat) => p.evaluate((pat) => {
335 const c = [...document.querySelectorAll('.files-mode-chip')]
336 .find(x => new RegExp(pat, 'i').test(x.textContent));
337 if (!c) return false;
338 c.click();
339 return true;
340}, pat);
341const clickBtn = (pat) => p.evaluate((pat) => {
342 const b = [...document.querySelectorAll('.files-mode-btn')]
343 .find(x => new RegExp(pat, 'i').test(x.textContent));
344 if (!b) return false;
345 b.click();
346 return true;
347}, pat);
348/// Does IndexedDB still hold a real directory handle for the workspace? Read from the account's
349/// own database name, exactly as FsaDB composes it.
350const storedHandle = () => p.evaluate(async () => {
351 const name = 'daimond-fsa'
352 + (window.DaimondAccounts && DaimondAccounts.opfsNs() ? '-' + DaimondAccounts.opfsNs() : '');
353 const db = await new Promise((res, rej) => {
354 const q = indexedDB.open(name, 1);
355 q.onupgradeneeded = () => q.result.createObjectStore('handles');
356 q.onsuccess = () => res(q.result);
357 q.onerror = () => rej(q.error);
358 });
359 const v = await new Promise((res) => {
360 const t = db.transaction('handles', 'readonly');
361 const r = t.objectStore('handles').get('workspace');
362 r.onsuccess = () => res(r.result);
363 r.onerror = () => res(undefined);
364 });
365 db.close();
366 return { held: !!v, isDir: !!(v && typeof v.getDirectoryHandle === 'function'), name: v ? v.name : '' };
367});
368
369// Back onto the folder, through the reconnect offer section E left standing. That is the user's
370// own way back and it needs no picker, so the counter must still be zero afterwards.
371await clickChip('Reconnect');
372await p.waitForTimeout(900);
373check('C2 setup: the reconnect offer puts the agent back on the folder',
374 (await mode()) === 'folder', await mode());
375
376// The ACTIVE Machine chip states the root's SCOPE. A user is being asked to point an agent at a
377// directory on their disk; the one thing they need told is how far it reaches. The chip used to
378// re-open the picker instead, which is the least useful thing it could do while already there.
379await zero();
380await clickChip('Machine|' + FOLDER);
381await p.waitForTimeout(600);
382const info = { msg: await msg(), picks: await picks() };
383check('the active Machine chip states what the agent can reach, and asks for no picker',
384 /works only inside/i.test(info.msg) && info.msg.includes(FOLDER) && info.picks === 0,
385 `"${info.msg}" · pickers: ${info.picks}`);
386
387// Changing the root is its own control. It is the ONE thing that should raise a picker.
388await zero();
389const hasChange = await clickBtn('Change folder');
390await p.waitForTimeout(600);
391check('a separate "Change folder…" control is what raises the picker',
392 hasChange === true && (await picks()) === 1,
393 `control present: ${hasChange}, pickers: ${await picks()}`);
394// And it starts where the user already is, rather than in a default they have never used.
395const opts = await p.evaluate(() => window.__pickOpts);
396check('and it opens where the current root is',
397 !!opts && opts.mode === 'readwrite' && !!opts.startIn && opts.startIn !== 'documents',
398 JSON.stringify(opts));
399
400// The round trip. Browser, then back -- and no picker anywhere in it.
401await zero();
402await clickChip('Browser');
403await p.waitForTimeout(900);
404const wentBrowser = await mode();
405const kept = await storedHandle();
406check('switching to the browser sandbox takes the agent there',
407 wentBrowser === 'opfs', wentBrowser);
408check('and does NOT delete the folder it is coming back to',
409 kept.held === true && kept.isDir === true, `stored: ${JSON.stringify(kept)}`);
410
411await clickChip('Machine|' + FOLDER);
412await p.waitForTimeout(1200);
413const wentBack = await mode();
414const backChips = await chips();
415check('and the way back is one click on the chip, with no folder picker',
416 wentBack === 'folder' && (await picks()) === 0,
417 `mode: ${wentBack}, pickers: ${await picks()}`);
418check('the chip names the folder the agent is back in',
419 backChips.active.includes(FOLDER), backChips.all);
420
421// The guard. Coming back is a ROOT SWAP, and a root swap while an agent is mid-turn leaves it
422// reading and writing somewhere else entirely. `openFolder` and `switchToOpfs` have always
423// refused; the reconnect path had no guard at all, and routing the chip through it is exactly
424// what would have made that gap reachable by an ordinary click.
425await clickChip('Browser');
426await p.waitForTimeout(700);
427await p.evaluate(() => { window.__busyWas = DaimondCore.busy; DaimondCore.busy = function () { return true; }; });
428await zero();
429await clickChip('Machine|' + FOLDER);
430await p.waitForTimeout(700);
431const blocked = { mode: await mode(), msg: await msg() };
432// THE PROPERTY. Two halves, and the first is the one that matters: with an
433// agent mid-turn the root DID NOT MOVE (`rootSwitchBlocked`, daimond.js, is
434// what every entry to a swap goes through, including the chip's reconnect
435// path). An agent resolves every path against one root, so a swap under it
436// writes the user's work into a folder nobody told it about. The guard returns
437// before the picker is ever raised, so nothing about the root is touched.
438//
439// The second half is that the person is told WHY it refused, which means the
440// message has to blame the running agent -- name it, hold the change off on
441// account of it, and say what is being held off. `/agent/` alone would pass for
442// "an agent did something, carry on"; dropping the message clause entirely
443// would pass for a silent refusal, which is what this used to be and is how it
444// reads as a broken chip.
445//
446// This was `/agent is working/`, which the copy stopped saying in those words
447// while saying the same thing: "Wait for the agent to finish before changing
448// where it works."
449const refusalBlamesTheAgent = (m) => ({
450 // The agent is named as the reason...
451 agent: /\bagent\b/i.test(m),
452 // ...the change is held off rather than reported as done...
453 held: /\b(wait|not|never|cannot|can[’']t|won[’']t|refus\w*|block\w*|busy|until|while|before|first|try again)\b/i.test(m),
454 // ...and what is held off is where the agent works.
455 root: /\b(where it works|where the agent works|workspace|folder|root|mov(e|ing)|chang(e|ing)|switch(ing)?)\b/i.test(m),
456 // ...and it is not a success notice wearing a warning's clothes.
457 notDone: !/\b(is now|now in|now at|switched to|moved to|changed to|has changed|done)\b/i.test(m),
458});
459const why = refusalBlamesTheAgent(blocked.msg);
460check('a root swap is refused while an agent is working, and blames the agent for the refusal',
461 blocked.mode === 'opfs' && why.agent && why.held && why.root && why.notDone,
462 `mode: ${blocked.mode} · "${blocked.msg}"`
463 + (Object.values(why).every(Boolean) ? '' : ' · ' + Object.entries(why)
464 .filter(([, v]) => !v).map(([k]) => 'no ' + k).join(', ')));
465await p.evaluate(() => { DaimondCore.busy = window.__busyWas; });
466
467// And the way out. Remembering the folder across a switch removes the only thing that used to
468// make Daimond forget it, so there has to be a deliberate way to say so.
469await clickChip('Machine|' + FOLDER);
470await p.waitForTimeout(900);
471await clickChip('Machine|' + FOLDER); // the info context, where Forget lives
472await p.waitForTimeout(400);
473const hasForget = await clickBtn('Forget this folder');
474await p.waitForTimeout(800);
475const forgotten = await storedHandle();
476check('and "Forget this folder" really does forget it',
477 hasForget === true && forgotten.held === false,
478 `control present: ${hasForget}, stored: ${JSON.stringify(forgotten)}`);
479
480await shot(s, 'fsa');
481const errs = s.errs.filter(e => !/favicon|404|401|net::ERR/.test(e));
482console.log('\nconsole errors:', errs.slice(0, 4));
483await s.close();
484
485console.log(`\n${ok.length} passed, ${bad.length} failed`);
486if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
487process.exit(bad.length ? 1 : 0);