staysfixed 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/v2/store.js CHANGED
@@ -108,14 +108,84 @@ export function newCaptureId(run, now = new Date()) {
108
108
 
109
109
  /**
110
110
  * Write a file so nobody can ever read it half-finished.
111
+ *
112
+ * Three things happen here that a plain `writeFile` does not do, and all three exist because
113
+ * his disk hit zero bytes on 2026-08-30.
114
+ *
115
+ * - The bytes are counted back off the disk before the file is renamed into place. A write
116
+ * onto a full disk can come back without throwing and with only some of the text on the
117
+ * platter, and a half-written references.json that gets renamed into place is a store that
118
+ * has quietly forgotten which build was working.
119
+ * - A failure says, in words, that the disk is full, rather than handing back a five-letter
120
+ * error code to somebody who is not a programmer.
121
+ * - The half-written temporary file is removed on the way out. Otherwise every failed write
122
+ * leaves its wreckage behind and the disk that was already full gets fuller.
123
+ *
111
124
  * @param {string} file
112
125
  * @param {string} text
113
126
  */
114
127
  async function writeAtomic(file, text) {
115
128
  await fsp.mkdir(path.dirname(file), { recursive: true });
116
129
  const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.part`;
117
- await fsp.writeFile(temp, text);
118
- await fsp.rename(temp, file);
130
+ const wanted = Buffer.byteLength(text, 'utf8');
131
+ try {
132
+ await fsp.writeFile(temp, text);
133
+ const landed = (await fsp.stat(temp)).size;
134
+ if (landed !== wanted) {
135
+ throw new StaysFixedError(
136
+ `Only ${landed} of ${wanted} bytes of ${path.basename(file)} reached the disk, so it was not saved.`,
137
+ { hint: 'The disk this project sits on is full, or something else is writing to the same folder. Free some space and run it again.' },
138
+ );
139
+ }
140
+ await fsp.rename(temp, file);
141
+ } catch (e) {
142
+ await fsp.rm(temp, { force: true }).catch(() => {});
143
+ throw noRoom(e, file);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The same failure, said in a way somebody who is not a programmer can act on.
149
+ *
150
+ * A full disk is the one storage failure that happens to real people mid-run, and ENOSPC on
151
+ * its own tells them nothing. Anything else is passed through untouched rather than dressed
152
+ * up as something it is not.
153
+ *
154
+ * @param {unknown} e
155
+ * @param {string} file
156
+ * @returns {unknown}
157
+ */
158
+ function noRoom(e, file) {
159
+ const code = /** @type {{code?: string}} */ (e)?.code;
160
+ if (code === 'ENOSPC') {
161
+ return new StaysFixedError(`There was no room left on the disk to save ${path.basename(file)}.`, {
162
+ hint: 'Free some space and run the check again. Nothing was lost except this record; the answer the run already worked out is still good.',
163
+ });
164
+ }
165
+ if (code === 'EDQUOT') {
166
+ return new StaysFixedError(`This account has used up its disk allowance, so ${path.basename(file)} could not be saved.`, {
167
+ hint: 'Ask for more space, or delete something, and run the check again.',
168
+ });
169
+ }
170
+ return e;
171
+ }
172
+
173
+ /**
174
+ * Is this the ordinary "there is no such file" that means nothing is wrong?
175
+ *
176
+ * Everything else — a permission that was taken away, a folder where a file should be, a
177
+ * disk that will not read — is a file that EXISTS and cannot be read, and those two answers
178
+ * must never come back as the same `null`. One of them means "this product has never been
179
+ * shipped"; the other means "the record of what working looks like is damaged", and a tool
180
+ * that reports the second as the first tells somebody to start from scratch when what they
181
+ * needed to hear was that their evidence is hurt.
182
+ *
183
+ * @param {unknown} e
184
+ * @returns {boolean}
185
+ */
186
+ function justNotThere(e) {
187
+ const code = /** @type {{code?: string}} */ (e)?.code;
188
+ return code === 'ENOENT' || code === 'ENOTDIR';
119
189
  }
120
190
 
121
191
  /**
@@ -134,7 +204,18 @@ export async function saveBuild(store, fingerprint, opts = {}) {
134
204
  if (!fingerprint.product) throw new StaysFixedError(`Build ${fingerprint.id} does not say which product it is of.`);
135
205
 
136
206
  const at = opts.at ?? new Date().toISOString();
137
- const existing = await loadBuild(store, fingerprint.id);
207
+ // A damaged record is loud everywhere else — a reader must never mistake it for a build
208
+ // nobody has heard of. Here it is different: this function is about to write a correct
209
+ // record over the top of it, and refusing would leave the damage in place for good and
210
+ // break every future run against that build. What is lost is the first-seen date and the
211
+ // journey list, which are rebuilt from the next few runs.
212
+ /** @type {BuildRecord|null} */
213
+ let existing = null;
214
+ try {
215
+ existing = await loadBuild(store, fingerprint.id);
216
+ } catch {
217
+ existing = null;
218
+ }
138
219
  const journeys = new Set(existing?.journeys ?? []);
139
220
  if (opts.journey) journeys.add(opts.journey);
140
221
 
@@ -203,29 +284,77 @@ export async function openCaptureWriter(store, opts) {
203
284
  if (opts.rules) shell.rules = opts.rules;
204
285
  await handle.write(JSON.stringify(headerOf(shell)) + '\n');
205
286
 
287
+ let open = true;
288
+ /** Close once, and never let a failure to close hide the failure that caused it. */
289
+ const shut = async () => {
290
+ if (!open) return;
291
+ open = false;
292
+ await handle.close().catch(() => {});
293
+ };
294
+
206
295
  return {
207
296
  ref,
208
297
  async append(o) {
209
298
  count++;
210
- await handle.write(JSON.stringify(o) + '\n');
299
+ await writeLine(handle, JSON.stringify(o) + '\n', temp, shut);
211
300
  },
212
301
  async close(end = {}) {
213
302
  const finished = { ...shell, durationMs: end.durationMs ?? Date.now() - started };
214
303
  if (end.coverage) finished.coverage = end.coverage;
215
304
  if (end.note) finished.note = end.note;
216
- await handle.write(JSON.stringify(endOf(finished, count)) + '\n');
217
- await handle.close();
218
- await fsp.rename(temp, ref.file);
305
+ await writeLine(handle, JSON.stringify(endOf(finished, count)) + '\n', temp, shut);
306
+ await shut();
307
+ try {
308
+ await fsp.rename(temp, ref.file);
309
+ } catch (e) {
310
+ await fsp.rm(temp, { force: true }).catch(() => {});
311
+ throw noRoom(e, ref.file);
312
+ }
219
313
  await bumpBuild(store, finished);
220
314
  return ref;
221
315
  },
222
316
  async abandon() {
223
- await handle.close();
224
- await fsp.rm(temp, { force: true });
317
+ // The file comes off the disk whatever the handle does. A close that throws used to
318
+ // leave the half-written capture sitting there for the sweeper to find an hour later.
319
+ await shut();
320
+ await fsp.rm(temp, { force: true }).catch(() => {});
225
321
  },
226
322
  };
227
323
  }
228
324
 
325
+ /**
326
+ * Write one whole line, or say plainly that it did not go on the disk.
327
+ *
328
+ * `handle.write` is allowed to write only part of what it was given and come back without
329
+ * throwing — which is exactly what a disk with a few hundred bytes left on it does. The half
330
+ * line that lands takes the newline with it, so the NEXT line is joined onto it and two
331
+ * observations are lost inside one unreadable line. `loadCapture` would still notice, because
332
+ * the end line counts what should be there; this stops it happening at all, and stops the
333
+ * run pretending the capture is finished.
334
+ *
335
+ * @param {import('node:fs/promises').FileHandle} handle
336
+ * @param {string} line
337
+ * @param {string} temp
338
+ * @param {() => Promise<void>} shut
339
+ * @returns {Promise<void>}
340
+ */
341
+ async function writeLine(handle, line, temp, shut) {
342
+ const wanted = Buffer.byteLength(line, 'utf8');
343
+ try {
344
+ const { bytesWritten } = await handle.write(line, null, 'utf8');
345
+ if (bytesWritten !== wanted) {
346
+ throw new StaysFixedError(
347
+ `Only ${bytesWritten} of ${wanted} bytes of this observation reached the disk, so the capture was abandoned rather than finished half-written.`,
348
+ { hint: 'The disk is full. Free some space and run the check again.' },
349
+ );
350
+ }
351
+ } catch (e) {
352
+ await shut();
353
+ await fsp.rm(temp, { force: true }).catch(() => {});
354
+ throw noRoom(e, temp);
355
+ }
356
+ }
357
+
229
358
  /**
230
359
  * @param {Store} store
231
360
  * @param {Capture} capture
@@ -315,8 +444,17 @@ export async function loadCapture(store, where) {
315
444
  let raw;
316
445
  try {
317
446
  raw = await fsp.readFile(file, 'utf8');
318
- } catch {
319
- return null;
447
+ } catch (e) {
448
+ // A capture that is not there and a capture that is there and unreadable came back as
449
+ // the same `null` until 2026-08-30, and they are opposite answers. "Not there" is the
450
+ // cold start and is fine. "There and unreadable" — a permission taken away, a folder
451
+ // where a file should be, a disk that will not read — means the record of what working
452
+ // looks like is damaged, and swallowing it turns damaged evidence into "no evidence",
453
+ // which reads as a clean start and lets a release through.
454
+ if (justNotThere(e)) return null;
455
+ throw new StaysFixedError(`${file} is there and could not be read: ${e instanceof Error ? e.message : String(e)}`, {
456
+ hint: 'This is a record of what the old build did. It is not missing, it is damaged, so nothing should be compared against it until it is either readable or deleted.',
457
+ });
320
458
  }
321
459
 
322
460
  const lines = raw.split('\n');
@@ -420,7 +558,10 @@ export async function listCaptures(store, opts) {
420
558
  /**
421
559
  * The most recent capture of one journey against one build.
422
560
  * @param {Store} store
423
- * @param {{buildId: string, journey: string, run?: CaptureRun}} opts
561
+ * @param {{buildId: string, journey: string, run?: CaptureRun, onProblem?: (message: string) => void}} opts
562
+ * `onProblem` is told about every record that had to be stepped over on the way back. The
563
+ * stepping over is right — one bad file must not cost the whole reference — but it means
564
+ * the answer is an OLDER record than the newest one, and nothing said so.
424
565
  * @returns {Promise<Capture|null>}
425
566
  */
426
567
  export async function latestCapture(store, opts) {
@@ -430,12 +571,15 @@ export async function latestCapture(store, opts) {
430
571
  let capture = null;
431
572
  try {
432
573
  capture = await loadCapture(store, refs[i]);
433
- } catch {
574
+ } catch (e) {
434
575
  // One file nobody can read must never take the whole reference with it. Asking for
435
576
  // a named capture that turns out not to be one is an error and stays one; scanning
436
577
  // for the newest usable record steps over it and keeps looking. Otherwise a single
437
578
  // interrupted run leaves the next check with "nothing to compare against", which
438
579
  // reads as a pass and lets a release through.
580
+ opts.onProblem?.(
581
+ `The newest stored record of "${opts.journey}" (${refs[i].captureId}) could not be read: ${e instanceof Error ? e.message : String(e)}. An older one was used instead, so this comparison is against something further back than it looks.`,
582
+ );
439
583
  continue;
440
584
  }
441
585
  if (!capture) continue;
@@ -451,11 +595,24 @@ export async function latestCapture(store, opts) {
451
595
  * @returns {Promise<BuildRecord|null>}
452
596
  */
453
597
  export async function loadBuild(store, buildId) {
598
+ const file = path.join(buildDir(store, buildId), 'build.json');
599
+ /** @type {string} */
600
+ let raw;
601
+ try {
602
+ raw = await fsp.readFile(file, 'utf8');
603
+ } catch (e) {
604
+ // Never seen is null. Seen and damaged is loud — see justNotThere.
605
+ if (justNotThere(e)) return null;
606
+ throw new StaysFixedError(`${file} is there and could not be read: ${e instanceof Error ? e.message : String(e)}`, {
607
+ hint: 'Nothing should treat this build as unknown while its record is sitting there damaged.',
608
+ });
609
+ }
454
610
  try {
455
- const raw = await fsp.readFile(path.join(buildDir(store, buildId), 'build.json'), 'utf8');
456
611
  return /** @type {BuildRecord} */ (JSON.parse(raw));
457
- } catch {
458
- return null;
612
+ } catch (e) {
613
+ throw new StaysFixedError(`${file} is not readable as JSON: ${e instanceof Error ? e.message : String(e)}`, {
614
+ hint: 'A half-written build record means a run was interrupted. Delete the file and run the check again; it will be rewritten.',
615
+ });
459
616
  }
460
617
  }
461
618
 
@@ -463,7 +620,11 @@ export async function loadBuild(store, buildId) {
463
620
  * Every build the store knows about, newest first.
464
621
  *
465
622
  * @param {Store} store
466
- * @param {{product?: string}} [opts]
623
+ * @param {{product?: string, onProblem?: (message: string) => void}} [opts]
624
+ * `onProblem` is told about every build folder that had to be skipped. Without it a build
625
+ * whose record is damaged simply is not in the list, and "not in the list" is how every
626
+ * caller spells "never existed" — so a build that HAS captures and a broken record reads
627
+ * as a build nobody ever made, in the coverage ledger and in `--against` alike.
467
628
  * @returns {Promise<BuildRecord[]>}
468
629
  */
469
630
  export async function listBuilds(store, opts = {}) {
@@ -476,9 +637,16 @@ export async function listBuilds(store, opts = {}) {
476
637
  try {
477
638
  const raw = await fsp.readFile(path.join(store.buildsDir, dirName, 'build.json'), 'utf8');
478
639
  record = /** @type {BuildRecord} */ (JSON.parse(raw));
479
- } catch {
640
+ } catch (e) {
480
641
  // A build folder with no readable record is not worth failing a run over. It happens
481
642
  // when a write was interrupted, and the next capture against that build rewrites it.
643
+ // It IS worth saying out loud, because everything above reads a missing build as a
644
+ // build that never existed.
645
+ opts.onProblem?.(
646
+ justNotThere(e)
647
+ ? `The build folder ${dirName} has no record in it, so whatever was stored against that build is not counted here.`
648
+ : `The build folder ${dirName} has a record that could not be read (${e instanceof Error ? e.message : String(e)}), so whatever was stored against that build is not counted here.`,
649
+ );
482
650
  continue;
483
651
  }
484
652
  const product = record.fingerprint?.product;
@@ -498,13 +666,36 @@ export async function listBuilds(store, opts = {}) {
498
666
  * @returns {Promise<Record<string, ReferencePointer>>}
499
667
  */
500
668
  async function loadReferences(store) {
669
+ /** @type {string} */
670
+ let raw;
501
671
  try {
502
- const raw = await fsp.readFile(store.referencesFile, 'utf8');
503
- const parsed = JSON.parse(raw);
504
- return parsed && typeof parsed === 'object' ? parsed : {};
505
- } catch {
506
- return {};
672
+ raw = await fsp.readFile(store.referencesFile, 'utf8');
673
+ } catch (e) {
674
+ // No file at all is the honest cold start: nothing has ever been shipped with the hook
675
+ // in place. A file that is there and cannot be read is the opposite — this product HAS a
676
+ // reference and the pointer to it is damaged — and returning an empty map for both meant
677
+ // a damaged store reported itself as a brand new one and every run after it compared
678
+ // against nothing while saying so in the gentlest possible words.
679
+ if (justNotThere(e)) return {};
680
+ throw new StaysFixedError(`${store.referencesFile} is there and could not be read: ${e instanceof Error ? e.message : String(e)}`, {
681
+ hint: 'This file is the only record of which build you called working. Until it can be read, no run can honestly say what it is comparing against.',
682
+ });
507
683
  }
684
+ /** @type {unknown} */
685
+ let parsed;
686
+ try {
687
+ parsed = JSON.parse(raw);
688
+ } catch (e) {
689
+ throw new StaysFixedError(`${store.referencesFile} is not readable as JSON: ${e instanceof Error ? e.message : String(e)}`, {
690
+ hint: 'It says which build of each product counts as working. Restore it from git, or ship again to write a fresh one. Treating it as empty would quietly turn every check into a first run.',
691
+ });
692
+ }
693
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
694
+ throw new StaysFixedError(`${store.referencesFile} does not hold a set of reference pointers.`, {
695
+ hint: 'It should be an object keyed by product name. Delete it and ship again rather than letting a check run against nothing.',
696
+ });
697
+ }
698
+ return /** @type {Record<string, ReferencePointer>} */ (parsed);
508
699
  }
509
700
 
510
701
  /**
@@ -598,9 +789,23 @@ export async function pruneBuild(store, buildId, opts = {}) {
598
789
  const keep = Math.max(1, opts.keepPerJourney ?? 4);
599
790
  const record = await loadBuild(store, buildId);
600
791
  const references = await loadReferences(store);
601
- if (record && references[record.fingerprint.product]?.buildId === buildId) {
602
- throw new StaysFixedError(`${buildId} is the reference for ${record.fingerprint.product}, so its observations cannot be pruned.`, {
603
- hint: 'Point the reference at a newer build first, with setReference.',
792
+
793
+ // Asked of the POINTERS, not of the build's own record. Reading it the other way round
794
+ // "what product does this build say it is of, and is that product's reference this build" —
795
+ // has a hole in it exactly where it matters: a build whose build.json is missing answers
796
+ // nothing, the guard is skipped, and the captures that are the only record of what working
797
+ // looks like get deleted. Every pointer is checked here, so an unreadable record can never
798
+ // be the reason a reference is thrown away.
799
+ const pointedAt = Object.values(references).filter((p) => p?.buildId === buildId);
800
+ if (pointedAt.length > 0) {
801
+ throw new StaysFixedError(
802
+ `${buildId} is the reference for ${pointedAt.map((p) => p.product).join(', ')}, so its observations cannot be pruned.`,
803
+ { hint: 'Point the reference at a newer build first, with setReference.' },
804
+ );
805
+ }
806
+ if (!record) {
807
+ throw new StaysFixedError(`Nothing here says what ${buildId} is, so its observations will not be thrown away.`, {
808
+ hint: 'Its build.json is missing. Deleting captures on the strength of a record nobody can read is how the evidence for "this used to work" disappears. Run a check against that build to rewrite the record, or delete the whole folder deliberately.',
604
809
  });
605
810
  }
606
811
 
@@ -636,25 +841,34 @@ export async function pruneBuild(store, buildId, opts = {}) {
636
841
  export async function sweepIncomplete(store, opts = {}) {
637
842
  const cutoff = Date.now() - (opts.olderThanMs ?? 60 * 60 * 1000);
638
843
  let removed = 0;
844
+
845
+ /** @param {string} dir */
846
+ const sweep = async (dir) => {
847
+ for (const name of await entries(dir)) {
848
+ if (!name.endsWith('.part')) continue;
849
+ const file = path.join(dir, name);
850
+ try {
851
+ const stat = await fsp.stat(file);
852
+ if (stat.mtimeMs > cutoff) continue;
853
+ await fsp.rm(file, { force: true });
854
+ removed++;
855
+ } catch {
856
+ // Gone while we looked at it. Somebody else's cleanup, and none of our business.
857
+ }
858
+ }
859
+ };
860
+
639
861
  const dirs = opts.buildId ? [safeName(opts.buildId)] : await subdirs(store.buildsDir);
640
862
  for (const buildDirName of dirs) {
641
863
  const base = path.join(store.buildsDir, buildDirName);
642
- for (const journeyDir of await subdirs(base)) {
643
- const full = path.join(base, journeyDir);
644
- for (const name of await entries(full)) {
645
- if (!name.endsWith('.part')) continue;
646
- const file = path.join(full, name);
647
- try {
648
- const stat = await fsp.stat(file);
649
- if (stat.mtimeMs > cutoff) continue;
650
- await fsp.rm(file, { force: true });
651
- removed++;
652
- } catch {
653
- // Gone while we looked at it. Somebody else's cleanup, and none of our business.
654
- }
655
- }
656
- }
864
+ // The build's own folder as well as each journey's. A half-written build.json lands
865
+ // beside the journey folders rather than inside one, so sweeping only the journeys left
866
+ // those behind for good invisible, and counting against a disk that was already full.
867
+ await sweep(base);
868
+ for (const journeyDir of await subdirs(base)) await sweep(path.join(base, journeyDir));
657
869
  }
870
+ // And the store's own root, where a half-written references.json lands.
871
+ if (!opts.buildId) await sweep(store.dir);
658
872
  return { removed };
659
873
  }
660
874
 
@@ -683,8 +897,15 @@ async function subdirs(dir) {
683
897
  try {
684
898
  const items = await fsp.readdir(dir, { withFileTypes: true });
685
899
  return items.filter((d) => d.isDirectory()).map((d) => d.name);
686
- } catch {
687
- return [];
900
+ } catch (e) {
901
+ // Not there is an empty list and always was. A folder that IS there and will not open —
902
+ // a permission taken away, a mount that went — used to come back as the same empty list,
903
+ // and an empty list here means "this product has never been walked". Everything above
904
+ // then reports a store full of evidence as a store with nothing in it.
905
+ if (justNotThere(e)) return [];
906
+ throw new StaysFixedError(`${dir} is there and could not be listed: ${e instanceof Error ? e.message : String(e)}`, {
907
+ hint: 'Reading it as empty would report everything already stored in it as never having been walked.',
908
+ });
688
909
  }
689
910
  }
690
911
 
@@ -697,7 +918,10 @@ async function entries(dir) {
697
918
  try {
698
919
  const items = await fsp.readdir(dir, { withFileTypes: true });
699
920
  return items.filter((d) => d.isFile()).map((d) => d.name);
700
- } catch {
701
- return [];
921
+ } catch (e) {
922
+ if (justNotThere(e)) return [];
923
+ throw new StaysFixedError(`${dir} is there and could not be listed: ${e instanceof Error ? e.message : String(e)}`, {
924
+ hint: 'Reading it as empty would report every capture inside it as never having been taken.',
925
+ });
702
926
  }
703
927
  }
@@ -44,6 +44,7 @@ import { attachPanel, panelPlan } from './events.js';
44
44
  /** @typedef {import('./window.js').Panel} Panel */
45
45
  /** @typedef {import('./window.js').BesideThis} BesideThis */
46
46
  /** @typedef {import('./window.js').PanelHealth} PanelHealth */
47
+ /** @typedef {import('./window.js').PanelBrowser} PanelBrowser */
47
48
 
48
49
  /** Panel width when nobody says otherwise. */
49
50
  const DEFAULT_WIDTH = 480;
@@ -52,16 +53,33 @@ const DEFAULT_WIDTH = 480;
52
53
  const SNAP_WAIT_MS = 6000;
53
54
 
54
55
  /**
55
- * How long stopping will wait for a window that is STILL opening.
56
+ * How long stopping will wait for a window that is STILL opening, when the window is going
57
+ * to be closed anyway.
56
58
  *
57
59
  * Short, and it has to be. A check that finishes in two seconds on a machine where a browser
58
60
  * takes twenty to start would otherwise sit there at the end, done, with nothing to say,
59
61
  * waiting on a window nobody is going to read. So stopping calls the opening off and gives it
60
62
  * a moment to tidy up; a window that arrives after that closes itself, because the opening
61
- * sequence already knows it was stopped.
63
+ * sequence already knows it was abandoned.
62
64
  */
63
65
  const STOP_WAIT_MS = 1500;
64
66
 
67
+ /**
68
+ * How long stopping will wait for a window that is still opening and is going to be LEFT UP.
69
+ *
70
+ * A different situation with the opposite answer, and getting the two confused is what made
71
+ * `--watch` show nothing at all on anything that finishes quickly. A browser takes two or
72
+ * three seconds to start; a check on a command-line product takes one. Under the short wait
73
+ * the run reached the end first, called the window off, and a person who explicitly asked to
74
+ * watch got no window and no result — every time, for the fastest and most common case.
75
+ *
76
+ * So when the window is going to stay up, the end of the check waits for it. The check itself
77
+ * is long over by then: nothing is being held up except the moment the terminal comes back,
78
+ * and the person asked for a window, so a window is what they get — with the whole run
79
+ * already drawn on it, because a late listener is handed everything it missed.
80
+ */
81
+ const LATE_WINDOW_MS = 25_000;
82
+
65
83
  /**
66
84
  * Anything with the two halves of an event stream on it.
67
85
  *
@@ -76,10 +94,12 @@ const STOP_WAIT_MS = 1500;
76
94
  /**
77
95
  * What the panel is told to do.
78
96
  *
79
- * `snap` lives here rather than in the shared options so that whether the two windows are
80
- * pushed together stays a decision of the watch code.
97
+ * The shared `WatchOptions` shape already carries every one of these, `snap` included, so
98
+ * this is a name for it rather than an extension of it. It used to add `snap` back on top;
99
+ * that stopped being true when the shared shape learned about it, and an intersection that
100
+ * adds nothing is a thing to read twice and understand once.
81
101
  *
82
- * @typedef {import('../../types.js').WatchOptions & {snap?: boolean}} PanelOptions
102
+ * @typedef {import('../../types.js').WatchOptions} PanelOptions
83
103
  */
84
104
 
85
105
  /**
@@ -92,6 +112,14 @@ const STOP_WAIT_MS = 1500;
92
112
  * @property {PanelOptions} [watch]
93
113
  * @property {string} [dir] Where to remember the window position. The project's own folder.
94
114
  * @property {{width: number, height: number}} [appViewport]
115
+ * @property {(browser: PanelBrowser) => void} [onOpen]
116
+ * Told once, when the window really is up, which browser
117
+ * it opened in and whether that browser belongs to the
118
+ * person. Nothing can know this in advance: the window is
119
+ * opened in the background and the browser is chosen while
120
+ * it opens. Anything that has to treat the panel's window
121
+ * as the tool's own — the screen guard does — has to be
122
+ * told rather than ask.
95
123
  */
96
124
 
97
125
  /**
@@ -105,11 +133,16 @@ const STOP_WAIT_MS = 1500;
105
133
  * `health` is there so the claim this whole file makes — that the window never held the check
106
134
  * up — has a number behind it rather than being taken on trust.
107
135
  *
136
+ * `browser` names what the window actually opened in, and whether that browser belongs to
137
+ * the person rather than to the tool. Anything that decides what "ours" means on this screen
138
+ * has to read it: a window in the person's own browser is not ours to push around.
139
+ *
108
140
  * @typedef {object} Watcher
109
141
  * @property {() => Promise<void>} stop
110
142
  * @property {(beside: BesideThis) => Promise<void>} snapTo
111
143
  * @property {() => PanelHealth|null} health
112
144
  * @property {() => boolean} open Is there a window right now.
145
+ * @property {() => PanelBrowser|null} browser
113
146
  */
114
147
 
115
148
  /**
@@ -143,7 +176,13 @@ function soon(work, ms) {
143
176
  * @returns {Watcher}
144
177
  */
145
178
  function noWatcher() {
146
- return { stop: async () => {}, snapTo: async () => {}, health: () => null, open: () => false };
179
+ return {
180
+ stop: async () => {},
181
+ snapTo: async () => {},
182
+ health: () => null,
183
+ open: () => false,
184
+ browser: () => null,
185
+ };
147
186
  }
148
187
 
149
188
  /**
@@ -187,11 +226,20 @@ export async function attachWatcher(events, opts = {}) {
187
226
  let panel = null;
188
227
  /** @type {(() => void)|null} */
189
228
  let unsubscribe = null;
229
+ // The check is over. Nothing new will be said.
190
230
  let stopped = false;
231
+ // ...and the window is not wanted at all, so one that arrives late puts itself away.
232
+ // Kept apart from `stopped` on purpose: a window that is going to be left up is still
233
+ // wanted after the check has finished, because the finished result is what it is for.
234
+ let abandoned = false;
191
235
  // How stopping reaches a window that has not finished opening. Every wait inside `openPanel`
192
236
  // watches this, so calling it off ends them all at once instead of one timeout at a time.
193
237
  const givingUp = new AbortController();
194
238
 
239
+ // A window that will be closed at the end is not worth waiting for; a window that will be
240
+ // left standing with the result on it is the whole reason somebody typed --watch.
241
+ const leaveItUp = watch.keepOpen !== false;
242
+
195
243
  /**
196
244
  * Start the window and, when it is up, start feeding it.
197
245
  *
@@ -209,13 +257,21 @@ export async function attachWatcher(events, opts = {}) {
209
257
  })
210
258
  .then((open) => {
211
259
  if (!open) return null;
212
- if (stopped) {
213
- // Stopped while it was still opening. Close it rather than leave a window nobody asked
214
- // for standing on somebody's screen.
260
+ if (abandoned) {
261
+ // Given up on while it was still opening. Close it rather than leave a window nobody
262
+ // asked for standing on somebody's screen.
215
263
  void open.close().catch(() => {});
216
264
  return null;
217
265
  }
218
266
  panel = open;
267
+ if (opts.onOpen) {
268
+ try {
269
+ opts.onOpen(open.browser);
270
+ } catch (e) {
271
+ // Somebody wanting to know is never allowed to be the reason a window fails.
272
+ detail(`The watch window opened, and telling the check about it went wrong. ${messageOf(e)}`);
273
+ }
274
+ }
219
275
  // Everything that already happened arrives here first, in order, before the first live
220
276
  // event does — which is the whole reason opening the window in the background is safe.
221
277
  //
@@ -258,23 +314,45 @@ export async function attachWatcher(events, opts = {}) {
258
314
  stop: () => {
259
315
  stopping ??= (async () => {
260
316
  stopped = true;
261
- // Stop listening first, so nothing new is queued while we are putting it away.
317
+
318
+ if (!panel && !leaveItUp) {
319
+ // It is still opening and it would only be closed again. Call it off, and give it a
320
+ // moment to tidy up rather than waiting it out. A window that arrives after this
321
+ // finds itself abandoned and closes itself.
322
+ abandoned = true;
323
+ givingUp.abort();
324
+ } else if (!panel) {
325
+ detail('The check finished before the watch window had opened. Waiting for it, because the result is what it is for.');
326
+ }
327
+
328
+ // Deliberately BEFORE anything stops listening. A window that opens now subscribes as
329
+ // it lands and is handed everything that already happened, in order, including the
330
+ // verdict — which is how a window that missed the whole run still shows all of it.
331
+ const open = panel ?? (await soon(opening, leaveItUp ? LATE_WINDOW_MS : STOP_WAIT_MS));
332
+
262
333
  try {
263
334
  if (unsubscribe) unsubscribe();
264
335
  } catch {
265
336
  // Already gone. Nothing left to stop listening to.
266
337
  }
267
- // Call off a window that is still opening, and do not wait it out. A window that
268
- // arrives late finds `stopped` already true and closes itself.
269
- givingUp.abort();
270
- const open = panel ?? (await soon(opening, STOP_WAIT_MS));
271
- if (open) await open.close().catch(() => {});
338
+
339
+ if (open) {
340
+ await open.close().catch(() => {});
341
+ } else {
342
+ // It never came. Nothing should be left starting up behind a finished check.
343
+ abandoned = true;
344
+ givingUp.abort();
345
+ }
272
346
  })();
273
347
  return stopping;
274
348
  },
275
349
 
276
350
  snapTo: async (beside) => {
277
351
  if (!maySnap) return;
352
+ // The check is over. Something that only just announced itself is something on its
353
+ // way out, and dragging windows around at the end of a run is worse than not
354
+ // arranging them at all.
355
+ if (stopped) return;
278
356
  try {
279
357
  const open = await panelSoon();
280
358
  if (open) await open.snapTo(beside);
@@ -287,6 +365,7 @@ export async function attachWatcher(events, opts = {}) {
287
365
 
288
366
  health: () => (panel ? panel.health() : null),
289
367
  open: () => panel !== null,
368
+ browser: () => (panel ? panel.browser : null),
290
369
  };
291
370
  }
292
371
 
@@ -322,8 +401,8 @@ export async function attachWatcher(events, opts = {}) {
322
401
  */
323
402
  export function watchOptionsFrom(config, cli) {
324
403
  const raw = config?.watch;
325
- // Read as PanelOptions on the way in: a settings file is free to carry a `snap` that the
326
- // shared shape does not yet describe.
404
+ // `watch: true` in a settings file is the short way of saying `watch: {enabled: true}`,
405
+ // and anything that is not an object at all says nothing.
327
406
  const settings = /** @type {PanelOptions} */ (
328
407
  raw === true ? { enabled: true } : raw && typeof raw === 'object' ? raw : {}
329
408
  );