staysfixed 0.7.2 → 0.9.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 (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  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 +564 -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/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
@@ -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) {