staysfixed 0.7.2 → 0.8.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/README.md +191 -55
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +18 -5
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +549 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/watch-flags.js +54 -0
  15. package/src/core/config.js +23 -3
  16. package/src/guard/run.js +49 -1
  17. package/src/report/console.js +15 -2
  18. package/src/v2/adapters/android-driver.js +6 -1
  19. package/src/v2/adapters/android.js +97 -2
  20. package/src/v2/adapters/contract.js +42 -5
  21. package/src/v2/adapters/electron.js +72 -6
  22. package/src/v2/adapters/http.js +11 -2
  23. package/src/v2/adapters/ios-driver.js +64 -14
  24. package/src/v2/adapters/ios.js +247 -25
  25. package/src/v2/adapters/process.js +728 -66
  26. package/src/v2/adapters/python.js +495 -0
  27. package/src/v2/adapters/source.js +373 -18
  28. package/src/v2/adapters/web-driver.js +94 -24
  29. package/src/v2/adapters/web.js +142 -9
  30. package/src/v2/adapters/windows.js +18 -1
  31. package/src/v2/browsers.js +9 -1
  32. package/src/v2/cause.js +61 -17
  33. package/src/v2/check.js +530 -66
  34. package/src/v2/ci.js +130 -35
  35. package/src/v2/cli.js +42 -24
  36. package/src/v2/cluster.js +164 -13
  37. package/src/v2/coverage.js +43 -176
  38. package/src/v2/detect.js +308 -60
  39. package/src/v2/doctor.js +285 -45
  40. package/src/v2/init.js +162 -61
  41. package/src/v2/intent.js +9 -23
  42. package/src/v2/journeys/from-suite.js +336 -30
  43. package/src/v2/journeys/index.js +99 -6
  44. package/src/v2/mcp/tools.js +10 -11
  45. package/src/v2/normalise.js +169 -23
  46. package/src/v2/observation.js +19 -33
  47. package/src/v2/rank.js +216 -23
  48. package/src/v2/reference.js +40 -10
  49. package/src/v2/remote.js +113 -18
  50. package/src/v2/run.js +103 -14
  51. package/src/v2/sealed.js +0 -20
  52. package/src/v2/selfcheck.js +190 -13
  53. package/src/v2/ship.js +29 -5
  54. package/src/v2/store.js +67 -1
  55. package/src/v2/types.js +12 -2
  56. package/src/v2/waiver.js +64 -54
  57. package/src/v2/watch/events.js +60 -215
  58. package/src/v2/watch/focus.js +14 -4
  59. package/src/v2/watch/panel.js +167 -17
@@ -597,6 +597,20 @@ export async function openApp(opts) {
597
597
  };
598
598
  }
599
599
 
600
+ /**
601
+ * A name a picture can be saved under, cut with a fingerprint of the whole on the end so two
602
+ * long names can never land on one file.
603
+ *
604
+ * @param {string} name
605
+ * @returns {string}
606
+ */
607
+ function pictureName(name) {
608
+ const clean = String(name).replace(/[^a-zA-Z0-9._-]+/g, '-');
609
+ if (clean === '') return 'a-walk';
610
+ if (clean.length <= 60) return clean;
611
+ return `${clean.slice(0, 51)}-${crypto.createHash('sha256').update(clean).digest('hex').slice(0, 8)}`;
612
+ }
613
+
600
614
  /**
601
615
  * Would this request leave the machine?
602
616
  *
@@ -640,6 +654,17 @@ export function asAddress(text, limit = 110) {
640
654
  return `${clean.slice(0, limit - 12)}… (${mark})`;
641
655
  }
642
656
 
657
+ /**
658
+ * How much of one control's own text is kept at its address.
659
+ *
660
+ * It protects the store and the diff: a text area holding a whole document would otherwise put
661
+ * that document into every capture and into every sentence written about it. Two hundred bytes
662
+ * is enough to recognise a field by. What breaks if it is wrong is nothing silent — the two
663
+ * ends and the exact byte count are kept either way, so getting this number wrong makes the
664
+ * record bigger or smaller, never quieter.
665
+ */
666
+ const CONTROL_TEXT_BYTES = 200;
667
+
643
668
  /**
644
669
  * The states worth writing down.
645
670
  *
@@ -665,21 +690,37 @@ const ROLES_WORTH_NOTHING = new Set(['generic', 'none', 'presentation', 'InlineT
665
690
  * identical, while a button that lost its label, went missing or went grey all show up as
666
691
  * exactly one difference each.
667
692
  *
693
+ * NOTHING HERE IS CUT WITHOUT SAYING SO. A control's own text used to be kept as its first
694
+ * two hundred characters and nothing else — no length, no fingerprint — so two builds whose
695
+ * text differed only past character two hundred recorded the same string and compared equal.
696
+ * A total, a message or an error at the end of a long field could change completely and the
697
+ * run would report that nothing had changed. `trimForStorage` had already solved this exactly
698
+ * once, for the output of a command: keep both ends and the EXACT number of bytes discarded,
699
+ * because a length survives normalisation while a digest of the whole text would not. It was
700
+ * never applied here. It is now, and what is still not covered — a change in the middle that
701
+ * leaves the length identical — is counted on `trimmed` and reported as a hole by the caller.
702
+ *
703
+ * The NAME is no longer cut here at all. It is part of an address, and cutting an address
704
+ * merges two things into one: two paragraphs sharing their first hundred and twenty
705
+ * characters became one address, so an edit further along either of them was invisible.
706
+ * `asAddress` already cuts addresses properly, leaving a fingerprint of the whole behind, and
707
+ * the caller passes every address through it.
708
+ *
668
709
  * @param {any[]} nodes Straight from Accessibility.getFullAXTree.
669
710
  * @param {(text: string) => string} [tidy] Rubs our own footprint out of the names.
670
- * @returns {{address: string, role: string, name: string, state: Record<string, string|number|boolean>}[]}
711
+ * @returns {{address: string, role: string, name: string, state: Record<string, string|number|boolean>, trimmed: boolean}[]}
671
712
  */
672
713
  export function readMeaning(nodes, tidy = (t) => t) {
673
714
  /** @type {Map<string, number>} */
674
715
  const seen = new Map();
675
- /** @type {{address: string, role: string, name: string, state: Record<string, string|number|boolean>}[]} */
716
+ /** @type {{address: string, role: string, name: string, state: Record<string, string|number|boolean>, trimmed: boolean}[]} */
676
717
  const rows = [];
677
718
 
678
719
  for (const node of nodes ?? []) {
679
720
  if (!node || node.ignored) continue;
680
721
  const role = String(node.role?.value ?? '');
681
722
  if (!role || ROLES_WORTH_NOTHING.has(role)) continue;
682
- const name = tidy(String(node.name?.value ?? '')).trim().replace(/\s+/g, ' ').slice(0, 120);
723
+ const name = tidy(String(node.name?.value ?? '')).trim().replace(/\s+/g, ' ');
683
724
 
684
725
  /** @type {Record<string, string|number|boolean>} */
685
726
  const state = {};
@@ -690,9 +731,18 @@ export function readMeaning(nodes, tidy = (t) => t) {
690
731
  if (value === undefined || value === false || value === 'false') continue;
691
732
  state[key] = typeof value === 'object' ? String(value) : value;
692
733
  }
734
+ let trimmed = false;
693
735
  const own = node.value?.value;
694
- if (own !== undefined && own !== null && String(own) !== '') state.value = tidy(String(own)).slice(0, 200);
695
- if (node.description?.value) state.described = tidy(String(node.description.value)).slice(0, 200);
736
+ if (own !== undefined && own !== null && String(own) !== '') {
737
+ const kept = trimForStorage(tidy(String(own)), CONTROL_TEXT_BYTES);
738
+ state.value = kept.text;
739
+ trimmed = trimmed || kept.truncated;
740
+ }
741
+ if (node.description?.value) {
742
+ const kept = trimForStorage(tidy(String(node.description.value)), CONTROL_TEXT_BYTES);
743
+ state.described = kept.text;
744
+ trimmed = trimmed || kept.truncated;
745
+ }
696
746
 
697
747
  // A control with no name is only worth an address when it says something else about
698
748
  // itself; an anonymous, stateless box is noise in every reading it appears in.
@@ -706,6 +756,7 @@ export function readMeaning(nodes, tidy = (t) => t) {
706
756
  role,
707
757
  name,
708
758
  state,
759
+ trimmed,
709
760
  });
710
761
  }
711
762
  return rows;
@@ -931,6 +982,18 @@ export function describeApp(input) {
931
982
  surface: 'electron',
932
983
  }));
933
984
  }
985
+ const tooLong = meaning.filter((row) => row.trimmed).length;
986
+ if (tooLong > 0) {
987
+ out.push(notCovered({
988
+ channel: 'meaning',
989
+ path: joinPath('count', id, 'controls holding more text than is kept'),
990
+ reason: 'too big',
991
+ says:
992
+ `${tooLong} control${tooLong === 1 ? '' : 's'} on this screen ${tooLong === 1 ? 'holds' : 'hold'} more text than is kept at one address. ` +
993
+ 'Both ends of it are compared and so is the exact number of bytes in between, so a change to either end, or one that makes the text longer or shorter, is still caught. ' +
994
+ 'A change buried in the middle that leaves the length exactly the same is not, and that is a hole rather than a pass.',
995
+ }));
996
+ }
934
997
  out.push(observation({
935
998
  channel: 'counters',
936
999
  path: joinPath('count', id, 'things on screen'),
@@ -1572,7 +1635,10 @@ async function takePicture(app, journey, ctx) {
1572
1635
  const shot = await app.browser.send('Page.captureScreenshot', { format: 'png' }, app.sessionId);
1573
1636
  const bytes = Buffer.from(String(shot?.data ?? ''), 'base64');
1574
1637
  if (bytes.length === 0) throw new Error('the app sent back an empty picture');
1575
- const file = path.join(ctx.evidenceDir, `${journey.name.replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 60)}.png`);
1638
+ // Fingerprinted rather than simply cut. Two journeys whose names agreed for sixty
1639
+ // characters saved their pictures over each other, and the evidence offered for one
1640
+ // finding was then a photograph of a different window, with nothing about it looking wrong.
1641
+ const file = path.join(ctx.evidenceDir, `${pictureName(journey.name)}.png`);
1576
1642
  await fsp.mkdir(ctx.evidenceDir, { recursive: true });
1577
1643
  await fsp.writeFile(file, bytes);
1578
1644
  return observation({
@@ -371,7 +371,13 @@ export const httpAdapter = defineAdapter({
371
371
  surface: 'server',
372
372
  from: route.file,
373
373
  channels: ['results', 'complaints', 'effects', 'counters'],
374
- steps: [{ act: 'request', method, route: route.name, url, unfilled }],
374
+ // `door` and `doorDetail` are how the coverage ledger learns that this journey walked
375
+ // that route. Without them a route counted as opened only if an observation happened to
376
+ // land at its own address, and this adapter writes its observations under `api.<journey
377
+ // name>` — so every route on every server read as never walked, on runs that had just
378
+ // asked the server for all of them. The verb is part of it: GET /basket and POST
379
+ // /basket are two doors.
380
+ steps: [{ act: 'request', method, route: route.name, url, unfilled, door: route.name, kind: 'route', doorDetail: route.detail ?? method }],
375
381
  // A route that changes something is walked — against a restored fixture, that is
376
382
  // the whole point. Only a route the project itself marks as irreversible is held
377
383
  // back, and even then only when nothing is watching to refuse the effect.
@@ -389,7 +395,10 @@ export const httpAdapter = defineAdapter({
389
395
  surface: 'server',
390
396
  from: 'the project config',
391
397
  channels: ['results', 'complaints', 'effects', 'counters'],
392
- steps: [{ act: 'request', method: String(extra.method ?? 'GET'), route: String(extra.url), url, unfilled, headers: extra.headers, body: extra.body }],
398
+ // A request written by hand in the settings names its own route, so it opens the same
399
+ // door the code reader found — as long as the url is the route's pattern rather than a
400
+ // filled-in one, which is the shape the settings ask for.
401
+ steps: [{ act: 'request', method: String(extra.method ?? 'GET'), route: String(extra.url), url, unfilled, headers: extra.headers, body: extra.body, door: String(extra.route ?? extra.url), kind: 'route', doorDetail: String(extra.method ?? 'GET') }],
393
402
  irreversible: extra.irreversible === true,
394
403
  });
395
404
  }
@@ -540,6 +540,16 @@ static NSString *gDir = nil;
540
540
  static NSMutableArray *gCalls = nil;
541
541
  static NSMutableArray *gRefused = nil;
542
542
 
543
+ // How far down a screen is read. A view hierarchy this deep is already extraordinary, and
544
+ // the limit is here because the walk is recursive and a malformed tree that points at
545
+ // itself would otherwise never come back. What it protects against is a hang; what it
546
+ // costs, when it is ever reached, is a control nobody can see or tap. So the number of
547
+ // places it turned round is counted and sent back with every answer, and the adapter turns
548
+ // that into a hole. Until 2026-08-30 it returned nil and said nothing.
549
+ static const int kDeepest = 60;
550
+ static NSInteger gDeeper = 0;
551
+ static NSInteger gFindDeeper = 0;
552
+
543
553
  static NSString *roleOf(UIAccessibilityTraits t) {
544
554
  if (t & UIAccessibilityTraitButton) return @"button";
545
555
  if (t & UIAccessibilityTraitLink) return @"link";
@@ -586,7 +596,8 @@ static NSArray *childrenOf(id node, int depth) {
586
596
  }
587
597
 
588
598
  static NSDictionary *describe(id node, int depth) {
589
- if (!node || depth > 60) return nil;
599
+ if (!node) return nil;
600
+ if (depth > kDeepest) { gDeeper += 1; return nil; }
590
601
  if ([node respondsToSelector:@selector(accessibilityElementsHidden)] && [node accessibilityElementsHidden]) return nil;
591
602
  NSString *label = [node respondsToSelector:@selector(accessibilityLabel)] ? [node accessibilityLabel] : nil;
592
603
  NSString *ident = [node respondsToSelector:@selector(accessibilityIdentifier)] ? [node accessibilityIdentifier] : nil;
@@ -622,6 +633,7 @@ static void turnAccessibilityOn(void) {
622
633
 
623
634
  static NSArray *snapshotTree(void) {
624
635
  turnAccessibilityOn();
636
+ gDeeper = 0;
625
637
  NSMutableArray *out = [NSMutableArray array];
626
638
  for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
627
639
  if (![scene isKindOfClass:[UIWindowScene class]]) continue;
@@ -636,9 +648,11 @@ static NSArray *snapshotTree(void) {
636
648
 
637
649
  static id findElement(NSString *wanted) {
638
650
  __block id found = nil;
651
+ gFindDeeper = 0;
639
652
  __block void (^scan)(id, int);
640
653
  scan = ^(id node, int depth) {
641
- if (found || !node || depth > 60) return;
654
+ if (found || !node) return;
655
+ if (depth > kDeepest) { gFindDeeper += 1; return; }
642
656
  NSString *ident = [node respondsToSelector:@selector(accessibilityIdentifier)] ? [node accessibilityIdentifier] : nil;
643
657
  NSString *label = [node respondsToSelector:@selector(accessibilityLabel)] ? [node accessibilityLabel] : nil;
644
658
  if ([ident isEqualToString:wanted] || [label isEqualToString:wanted]) { found = node; return; }
@@ -704,11 +718,16 @@ static void runCommand(NSDictionary *cmd, NSString *seq) {
704
718
  res[@"act"] = act;
705
719
  if ([act isEqualToString:@"tree"]) {
706
720
  res[@"tree"] = snapshotTree();
721
+ res[@"deeper"] = @(gDeeper);
707
722
  } else if ([act isEqualToString:@"tap"] || [act isEqualToString:@"type"]) {
708
723
  id el = findElement(cmd[@"target"]);
724
+ res[@"deeper"] = @(gFindDeeper);
709
725
  if (!el) {
710
726
  res[@"ok"] = @NO;
711
- res[@"why"] = [NSString stringWithFormat:@"nothing on this screen is called '%@'", cmd[@"target"] ?: @""];
727
+ NSString *stopped = gFindDeeper > 0
728
+ ? [NSString stringWithFormat:@", and the search turned round at %d levels deep in %ld place(s), so it may be further in than that", kDeepest, (long)gFindDeeper]
729
+ : @"";
730
+ res[@"why"] = [NSString stringWithFormat:@"nothing on this screen is called '%@'%@", cmd[@"target"] ?: @"", stopped];
712
731
  } else if ([act isEqualToString:@"type"]) {
713
732
  BOOL ok = NO;
714
733
  if ([el isKindOfClass:[UITextField class]]) { [(UITextField *)el setText:cmd[@"text"]]; ok = YES; }
@@ -869,12 +888,17 @@ export async function buildProbe(opts) {
869
888
  * @property {boolean} probeAnswered False means the screen cannot be read, only pictured.
870
889
  * @property {string} why
871
890
  * @property {(act: string, args?: Record<string, unknown>, timeoutMs?: number) => Promise<any>} ask
872
- * @property {() => Promise<MeaningNode[]>} tree
891
+ * @property {() => Promise<{nodes: MeaningNode[], deeper: number}>} tree
892
+ * The screen, and how many places the read turned round at the depth limit without being
893
+ * able to see what was below. `deeper` is almost always zero; when it is not, something on
894
+ * screen was not read at all and the walk has to say so.
873
895
  * @property {(target: string) => Promise<{ok: boolean, why: string}>} tap
874
896
  * @property {(target: string, text: string) => Promise<{ok: boolean, why: string}>} type
875
897
  * @property {() => Promise<{calls: Call[], refused: Call[]}>} calls
876
898
  * @property {(file: string) => Promise<{ok: boolean, path: string, why: string}>} screenshot
877
- * @property {() => Promise<string[]>} filesWritten
899
+ * @property {(collect?: {unreadable?: string[]}) => Promise<string[]>} filesWritten
900
+ * Pass an object with an `unreadable` array to be told which folders inside the app's own
901
+ * container would not open. Without it those folders are still skipped and nothing says so.
878
902
  * @property {() => Promise<void>} close
879
903
  */
880
904
 
@@ -1030,7 +1054,10 @@ export async function openApp(opts) {
1030
1054
  ask,
1031
1055
  tree: async () => {
1032
1056
  const reply = await ask('tree', {}, 30_000);
1033
- return Array.isArray(reply?.tree) ? reply.tree : [];
1057
+ return {
1058
+ nodes: Array.isArray(reply?.tree) ? reply.tree : [],
1059
+ deeper: Number(reply?.deeper ?? 0) || 0,
1060
+ };
1034
1061
  },
1035
1062
  tap: async (target) => {
1036
1063
  const reply = await ask('tap', { target });
@@ -1045,7 +1072,7 @@ export async function openApp(opts) {
1045
1072
  return { calls: Array.isArray(reply?.calls) ? reply.calls : [], refused: Array.isArray(reply?.refused) ? reply.refused : [] };
1046
1073
  },
1047
1074
  screenshot: async (file) => takeScreenshot(opts.udid, file, { signal: opts.signal }),
1048
- filesWritten: async () => listContainerFiles(container),
1075
+ filesWritten: async (collect = {}) => listContainerFiles(container, collect),
1049
1076
  close: async () => {
1050
1077
  await simctl(['terminate', opts.udid, facts.bundleId], { timeoutMs: 60_000 });
1051
1078
  },
@@ -1259,9 +1286,9 @@ function describeRole(role) {
1259
1286
  * mid-animation gives a tree with half a screen in it — which then reports as a difference
1260
1287
  * caused by nothing.
1261
1288
  *
1262
- * @param {() => Promise<MeaningNode[]>} read
1289
+ * @param {() => Promise<{nodes: MeaningNode[], deeper: number}>} read
1263
1290
  * @param {{tries?: number, gapMs?: number, signal?: AbortSignal}} [opts]
1264
- * @returns {Promise<{tree: MeaningNode[], settled: boolean, tries: number, why: string}>}
1291
+ * @returns {Promise<{tree: MeaningNode[], deeper: number, settled: boolean, tries: number, why: string}>}
1265
1292
  */
1266
1293
  export async function settleTree(read, opts = {}) {
1267
1294
  const tries = opts.tries ?? 6;
@@ -1269,18 +1296,25 @@ export async function settleTree(read, opts = {}) {
1269
1296
  let previous = '';
1270
1297
  /** @type {MeaningNode[]} */
1271
1298
  let tree = [];
1299
+ // How many places the last read could not see past. It travels out with the tree because
1300
+ // the caller has to report it: a control the reader turned round above is not on the screen
1301
+ // as far as everything downstream is concerned, and nothing else would ever mention it.
1302
+ let deeper = 0;
1272
1303
  for (let i = 1; i <= tries; i += 1) {
1273
1304
  if (opts.signal?.aborted) break;
1274
- tree = await read();
1305
+ const read1 = await read();
1306
+ tree = read1.nodes;
1307
+ deeper = read1.deeper;
1275
1308
  const now = JSON.stringify(tree);
1276
1309
  if (now === previous && now !== '[]') {
1277
- return { tree, settled: true, tries: i, why: `The screen was the same twice in a row after ${i} looks.` };
1310
+ return { tree, deeper, settled: true, tries: i, why: `The screen was the same twice in a row after ${i} looks.` };
1278
1311
  }
1279
1312
  previous = now;
1280
1313
  if (i < tries) await wait(gap);
1281
1314
  }
1282
1315
  return {
1283
1316
  tree,
1317
+ deeper,
1284
1318
  settled: false,
1285
1319
  tries,
1286
1320
  why: `The screen was still changing after ${tries} looks, so what was read may have caught it mid-move. Anything that differs here should be treated as the app's own wobble until a second run says otherwise.`,
@@ -1340,6 +1374,9 @@ export async function readAppLog(opts) {
1340
1374
  return { lines, ok: true, why: `${lines.length} line${lines.length === 1 ? '' : 's'} the app itself wrote.` };
1341
1375
  }
1342
1376
 
1377
+ /** How much of one crash report is searched for the reason. @see readCrashes */
1378
+ const CRASH_REPORT_HEAD = 20_000;
1379
+
1343
1380
  /**
1344
1381
  * Crashes, from the folder the operating system puts them in.
1345
1382
  *
@@ -1378,7 +1415,11 @@ export async function readCrashes(opts) {
1378
1415
  if (stat.mtimeMs < opts.since) continue;
1379
1416
  let reason = 'it stopped without saying why';
1380
1417
  try {
1381
- const text = (await fsp.readFile(full, 'utf8')).slice(0, 20_000);
1418
+ // Only the head of the report is searched, because the rest of it is a megabyte of
1419
+ // stack and the reason is written near the top. Getting this number wrong cannot hide
1420
+ // a crash — the crash is recorded either way, with "it stopped without saying why" —
1421
+ // it can only make the reason vaguer than it needed to be.
1422
+ const text = (await fsp.readFile(full, 'utf8')).slice(0, CRASH_REPORT_HEAD);
1382
1423
  const term = /"termination"\s*:\s*\{[^}]*"indicator"\s*:\s*"([^"]+)"/.exec(text);
1383
1424
  const exception = /"exception"\s*:\s*\{[^}]*"type"\s*:\s*"([^"]+)"/.exec(text);
1384
1425
  const legacy = /Exception Type:\s*(.+)/.exec(text);
@@ -1415,10 +1456,16 @@ export async function readCrashes(opts) {
1415
1456
  * What is NOT rubbed out is any random id in a name the app chose itself. A database file
1416
1457
  * that used to have a stable name and now has a random one is a real finding.
1417
1458
  *
1459
+ * A FOLDER IT CANNOT OPEN IS NAMED. It used to `return` on the read that failed, which threw
1460
+ * away that folder and every file underneath it in silence — so "the app wrote nothing there"
1461
+ * and "nobody could look" came back as the same answer, and the second one is not a pass.
1462
+ *
1418
1463
  * @param {string} container
1464
+ * @param {{unreadable?: string[]}} [collect]
1465
+ * Hand in an object and any folder that would not open is pushed onto `collect.unreadable`.
1419
1466
  * @returns {Promise<string[]>}
1420
1467
  */
1421
- export async function listContainerFiles(container) {
1468
+ export async function listContainerFiles(container, collect = {}) {
1422
1469
  /** @type {string[]} */
1423
1470
  const out = [];
1424
1471
  /** @param {string} dir */
@@ -1427,7 +1474,10 @@ export async function listContainerFiles(container) {
1427
1474
  let entries = [];
1428
1475
  try {
1429
1476
  entries = await fsp.readdir(dir, { withFileTypes: true });
1430
- } catch {
1477
+ } catch (error) {
1478
+ const where = path.relative(container, dir) || '.';
1479
+ const said = String(/** @type {any} */ (error)?.code ?? /** @type {any} */ (error)?.message ?? 'no reason given');
1480
+ if (collect.unreadable) collect.unreadable.push(`${where} (${said})`);
1431
1481
  return;
1432
1482
  }
1433
1483
  for (const entry of entries) {