staysfixed 0.7.1 → 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.
- package/CHANGELOG.md +364 -0
- package/README.md +193 -55
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +18 -5
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +549 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +23 -3
- package/src/guard/run.js +49 -1
- package/src/report/console.js +15 -2
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +11 -2
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +728 -66
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +94 -24
- package/src/v2/adapters/web.js +142 -9
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +9 -1
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +530 -66
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +42 -24
- package/src/v2/cluster.js +164 -13
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +345 -47
- package/src/v2/init.js +162 -61
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +10 -11
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +40 -10
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +29 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- 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, ' ')
|
|
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) !== '')
|
|
695
|
-
|
|
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
|
-
|
|
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({
|
package/src/v2/adapters/http.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|