lynkeus 0.2.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/lib/__tests__/args.test.d.ts +1 -0
- package/lib/__tests__/args.test.js +69 -0
- package/lib/__tests__/check.test.d.ts +1 -0
- package/lib/__tests__/check.test.js +60 -0
- package/lib/__tests__/init.test.d.ts +1 -0
- package/lib/__tests__/init.test.js +42 -0
- package/lib/__tests__/record.test.d.ts +1 -0
- package/lib/__tests__/record.test.js +44 -0
- package/lib/__tests__/snapshot.test.d.ts +1 -0
- package/lib/__tests__/snapshot.test.js +47 -0
- package/lib/__tests__/targets.test.d.ts +1 -0
- package/lib/__tests__/targets.test.js +127 -0
- package/lib/args.d.ts +30 -0
- package/lib/args.js +86 -0
- package/lib/cases/__tests__/format.test.d.ts +1 -0
- package/lib/cases/__tests__/format.test.js +61 -0
- package/lib/cases/__tests__/runner.test.d.ts +1 -0
- package/lib/cases/__tests__/runner.test.js +188 -0
- package/lib/cases/fixtures.d.ts +13 -0
- package/lib/cases/fixtures.js +100 -0
- package/lib/cases/format.d.ts +37 -0
- package/lib/cases/format.js +169 -0
- package/lib/cases/runner.d.ts +61 -0
- package/lib/cases/runner.js +590 -0
- package/lib/cases/templates.d.ts +20 -0
- package/lib/cases/templates.js +156 -0
- package/lib/cli.d.ts +6 -0
- package/lib/cli.js +173 -0
- package/lib/commands/cases.d.ts +1 -0
- package/lib/commands/cases.js +179 -0
- package/lib/commands/check.d.ts +36 -0
- package/lib/commands/check.js +67 -0
- package/lib/commands/crawl/return.d.ts +20 -0
- package/lib/commands/crawl/return.js +125 -0
- package/lib/commands/crawl/state.d.ts +30 -0
- package/lib/commands/crawl/state.js +56 -0
- package/lib/commands/crawl.d.ts +67 -0
- package/lib/commands/crawl.js +213 -0
- package/lib/commands/device.d.ts +10 -0
- package/lib/commands/device.js +122 -0
- package/lib/commands/doctor.d.ts +3 -0
- package/lib/commands/doctor.js +240 -0
- package/lib/commands/drive.d.ts +1 -0
- package/lib/commands/drive.js +410 -0
- package/lib/commands/flake.d.ts +14 -0
- package/lib/commands/flake.js +42 -0
- package/lib/commands/headless.d.ts +1 -0
- package/lib/commands/headless.js +127 -0
- package/lib/commands/init.d.ts +13 -0
- package/lib/commands/init.js +85 -0
- package/lib/commands/map.d.ts +2 -0
- package/lib/commands/map.js +297 -0
- package/lib/commands/os.d.ts +1 -0
- package/lib/commands/os.js +193 -0
- package/lib/commands/overlay.d.ts +9 -0
- package/lib/commands/overlay.js +12 -0
- package/lib/commands/record.d.ts +5 -0
- package/lib/commands/record.js +55 -0
- package/lib/commands/runs.d.ts +1 -0
- package/lib/commands/runs.js +452 -0
- package/lib/commands/smoke.d.ts +28 -0
- package/lib/commands/smoke.js +59 -0
- package/lib/commands/snapshot.d.ts +27 -0
- package/lib/commands/snapshot.js +69 -0
- package/lib/config.d.ts +72 -0
- package/lib/config.js +44 -0
- package/lib/device/devices.d.ts +11 -0
- package/lib/device/devices.js +33 -0
- package/lib/device/driver.d.ts +53 -0
- package/lib/device/driver.js +169 -0
- package/lib/device/platform.d.ts +7 -0
- package/lib/device/platform.js +59 -0
- package/lib/files.d.ts +7 -0
- package/lib/files.js +29 -0
- package/lib/flow/artifacts.d.ts +19 -0
- package/lib/flow/artifacts.js +49 -0
- package/lib/flow/runner.d.ts +13 -0
- package/lib/flow/runner.js +169 -0
- package/lib/flow/types.d.ts +111 -0
- package/lib/flow/types.js +1 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +11 -0
- package/lib/knowledge/__tests__/edges.test.d.ts +1 -0
- package/lib/knowledge/__tests__/edges.test.js +25 -0
- package/lib/knowledge/__tests__/events.test.d.ts +1 -0
- package/lib/knowledge/__tests__/events.test.js +39 -0
- package/lib/knowledge/__tests__/extract.test.d.ts +1 -0
- package/lib/knowledge/__tests__/extract.test.js +69 -0
- package/lib/knowledge/__tests__/frontier.test.d.ts +1 -0
- package/lib/knowledge/__tests__/frontier.test.js +57 -0
- package/lib/knowledge/__tests__/funnels.test.d.ts +1 -0
- package/lib/knowledge/__tests__/funnels.test.js +40 -0
- package/lib/knowledge/__tests__/lint.test.d.ts +1 -0
- package/lib/knowledge/__tests__/lint.test.js +53 -0
- package/lib/knowledge/__tests__/perf.test.d.ts +1 -0
- package/lib/knowledge/__tests__/perf.test.js +34 -0
- package/lib/knowledge/__tests__/plan.test.d.ts +1 -0
- package/lib/knowledge/__tests__/plan.test.js +35 -0
- package/lib/knowledge/__tests__/query.test.d.ts +1 -0
- package/lib/knowledge/__tests__/query.test.js +55 -0
- package/lib/knowledge/__tests__/screens.test.d.ts +1 -0
- package/lib/knowledge/__tests__/screens.test.js +30 -0
- package/lib/knowledge/__tests__/why.test.d.ts +1 -0
- package/lib/knowledge/__tests__/why.test.js +28 -0
- package/lib/knowledge/edges.d.ts +30 -0
- package/lib/knowledge/edges.js +68 -0
- package/lib/knowledge/errors.d.ts +8 -0
- package/lib/knowledge/errors.js +62 -0
- package/lib/knowledge/events.d.ts +23 -0
- package/lib/knowledge/events.js +51 -0
- package/lib/knowledge/extract.d.ts +26 -0
- package/lib/knowledge/extract.js +365 -0
- package/lib/knowledge/frontier.d.ts +26 -0
- package/lib/knowledge/frontier.js +60 -0
- package/lib/knowledge/funnels.d.ts +46 -0
- package/lib/knowledge/funnels.js +103 -0
- package/lib/knowledge/lint.d.ts +16 -0
- package/lib/knowledge/lint.js +89 -0
- package/lib/knowledge/perf.d.ts +32 -0
- package/lib/knowledge/perf.js +91 -0
- package/lib/knowledge/plan.d.ts +22 -0
- package/lib/knowledge/plan.js +81 -0
- package/lib/knowledge/problems.d.ts +5 -0
- package/lib/knowledge/problems.js +8 -0
- package/lib/knowledge/query.d.ts +36 -0
- package/lib/knowledge/query.js +127 -0
- package/lib/knowledge/route-params.d.ts +6 -0
- package/lib/knowledge/route-params.js +42 -0
- package/lib/knowledge/screens.d.ts +30 -0
- package/lib/knowledge/screens.js +125 -0
- package/lib/knowledge/tree.d.ts +15 -0
- package/lib/knowledge/tree.js +63 -0
- package/lib/knowledge/types.d.ts +62 -0
- package/lib/knowledge/types.js +6 -0
- package/lib/knowledge/why.d.ts +24 -0
- package/lib/knowledge/why.js +153 -0
- package/lib/mcp/server.d.ts +4 -0
- package/lib/mcp/server.js +111 -0
- package/lib/registry.d.ts +78 -0
- package/lib/registry.js +113 -0
- package/lib/runtime.d.ts +12 -0
- package/lib/runtime.js +56 -0
- package/lib/targets.d.ts +17 -0
- package/lib/targets.js +43 -0
- package/package.json +47 -0
- package/src/__tests__/args.test.ts +82 -0
- package/src/__tests__/check.test.ts +71 -0
- package/src/__tests__/init.test.ts +52 -0
- package/src/__tests__/record.test.ts +49 -0
- package/src/__tests__/snapshot.test.ts +51 -0
- package/src/__tests__/targets.test.ts +132 -0
- package/src/args.ts +111 -0
- package/src/cases/__tests__/format.test.ts +76 -0
- package/src/cases/__tests__/runner.test.ts +230 -0
- package/src/cases/fixtures.ts +104 -0
- package/src/cases/format.ts +183 -0
- package/src/cases/runner.ts +575 -0
- package/src/cases/templates.ts +159 -0
- package/src/cli.ts +176 -0
- package/src/commands/cases.ts +170 -0
- package/src/commands/check.ts +83 -0
- package/src/commands/crawl/return.ts +130 -0
- package/src/commands/crawl/state.ts +70 -0
- package/src/commands/crawl.ts +262 -0
- package/src/commands/device.ts +119 -0
- package/src/commands/doctor.ts +237 -0
- package/src/commands/drive.ts +413 -0
- package/src/commands/flake.ts +48 -0
- package/src/commands/headless.ts +131 -0
- package/src/commands/init.ts +98 -0
- package/src/commands/map.ts +298 -0
- package/src/commands/os.ts +195 -0
- package/src/commands/overlay.ts +20 -0
- package/src/commands/record.ts +46 -0
- package/src/commands/runs.ts +475 -0
- package/src/commands/smoke.ts +86 -0
- package/src/commands/snapshot.ts +87 -0
- package/src/config.ts +103 -0
- package/src/device/devices.ts +44 -0
- package/src/device/driver.ts +197 -0
- package/src/device/platform.ts +74 -0
- package/src/files.ts +35 -0
- package/src/flow/artifacts.ts +61 -0
- package/src/flow/runner.ts +182 -0
- package/src/flow/types.ts +55 -0
- package/src/index.ts +18 -0
- package/src/knowledge/__tests__/edges.test.ts +34 -0
- package/src/knowledge/__tests__/events.test.ts +45 -0
- package/src/knowledge/__tests__/extract.test.ts +88 -0
- package/src/knowledge/__tests__/frontier.test.ts +77 -0
- package/src/knowledge/__tests__/funnels.test.ts +58 -0
- package/src/knowledge/__tests__/lint.test.ts +66 -0
- package/src/knowledge/__tests__/perf.test.ts +41 -0
- package/src/knowledge/__tests__/plan.test.ts +52 -0
- package/src/knowledge/__tests__/query.test.ts +74 -0
- package/src/knowledge/__tests__/screens.test.ts +37 -0
- package/src/knowledge/__tests__/why.test.ts +40 -0
- package/src/knowledge/edges.ts +91 -0
- package/src/knowledge/errors.ts +73 -0
- package/src/knowledge/events.ts +60 -0
- package/src/knowledge/extract.ts +379 -0
- package/src/knowledge/frontier.ts +82 -0
- package/src/knowledge/funnels.ts +126 -0
- package/src/knowledge/lint.ts +99 -0
- package/src/knowledge/perf.ts +114 -0
- package/src/knowledge/plan.ts +95 -0
- package/src/knowledge/problems.ts +12 -0
- package/src/knowledge/query.ts +146 -0
- package/src/knowledge/route-params.ts +39 -0
- package/src/knowledge/screens.ts +139 -0
- package/src/knowledge/tree.ts +66 -0
- package/src/knowledge/types.ts +60 -0
- package/src/knowledge/why.ts +189 -0
- package/src/mcp/server.ts +109 -0
- package/src/registry.ts +191 -0
- package/src/runtime.ts +54 -0
- package/src/targets.ts +44 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { TraceEvent } from 'lynkeus-protocol';
|
|
2
|
+
|
|
3
|
+
export type Visit = {
|
|
4
|
+
route: string;
|
|
5
|
+
at: number;
|
|
6
|
+
/** How long the screen stayed on top. */
|
|
7
|
+
ms: number;
|
|
8
|
+
requests: number;
|
|
9
|
+
requestMs: number;
|
|
10
|
+
errors: number;
|
|
11
|
+
slowest?: { method: string; path: string; ms: number };
|
|
12
|
+
/** From arriving to the end of the opening burst of requests. */
|
|
13
|
+
quietAfterMs: number;
|
|
14
|
+
stalls: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const pathOf = (url: string) => {
|
|
18
|
+
try {
|
|
19
|
+
const u = new URL(url);
|
|
20
|
+
return u.pathname + u.search;
|
|
21
|
+
} catch {
|
|
22
|
+
return url;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// This long with no request ends the opening burst: later polling does not count against arriving.
|
|
27
|
+
const QUIET_GAP_MS = 1500;
|
|
28
|
+
|
|
29
|
+
export const visitsFrom = (events: TraceEvent[]): Visit[] => {
|
|
30
|
+
const visits: Visit[] = [];
|
|
31
|
+
let current: Visit | null = null;
|
|
32
|
+
let burstEnd = 0;
|
|
33
|
+
let burstOver = false;
|
|
34
|
+
const close = (endT: number) => {
|
|
35
|
+
if (current) current.ms = Math.max(0, endT - current.at);
|
|
36
|
+
};
|
|
37
|
+
for (const e of events) {
|
|
38
|
+
if (e.kind === 'route') {
|
|
39
|
+
close(e.t);
|
|
40
|
+
current = { route: e.route ?? e.path.join('/') ?? '?', at: e.t, ms: 0, requests: 0, requestMs: 0, errors: 0, quietAfterMs: 0, stalls: 0 };
|
|
41
|
+
visits.push(current);
|
|
42
|
+
burstEnd = e.t;
|
|
43
|
+
burstOver = false;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!current) continue;
|
|
47
|
+
if (e.kind === 'request') {
|
|
48
|
+
current.requests += 1;
|
|
49
|
+
const ms = e.ms ?? 0;
|
|
50
|
+
current.requestMs += ms;
|
|
51
|
+
if ((e.status ?? 0) >= 400 || e.error) current.errors += 1;
|
|
52
|
+
if (!current.slowest || ms > current.slowest.ms) current.slowest = { method: e.method, path: pathOf(e.url), ms };
|
|
53
|
+
// A request event is stamped when it ends.
|
|
54
|
+
const start = e.t - ms;
|
|
55
|
+
if (!burstOver && start - burstEnd > QUIET_GAP_MS) burstOver = true;
|
|
56
|
+
if (!burstOver) {
|
|
57
|
+
burstEnd = Math.max(burstEnd, e.t);
|
|
58
|
+
current.quietAfterMs = Math.max(current.quietAfterMs, burstEnd - current.at);
|
|
59
|
+
}
|
|
60
|
+
} else if (e.kind === 'stall') {
|
|
61
|
+
current.stalls += 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const last = events[events.length - 1];
|
|
65
|
+
if (last) close(last.t);
|
|
66
|
+
return visits;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type RouteSummary = {
|
|
70
|
+
route: string;
|
|
71
|
+
visits: number;
|
|
72
|
+
requests: number;
|
|
73
|
+
requestMs: number;
|
|
74
|
+
quietAfterMs: number;
|
|
75
|
+
errors: number;
|
|
76
|
+
stalls: number;
|
|
77
|
+
slowest?: Visit['slowest'];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Per route, averaged over its visits, the costliest arrival first. */
|
|
81
|
+
export const summarize = (visits: Visit[]): RouteSummary[] => {
|
|
82
|
+
const by = new Map<string, RouteSummary>();
|
|
83
|
+
for (const v of visits) {
|
|
84
|
+
const s = by.get(v.route) ?? { route: v.route, visits: 0, requests: 0, requestMs: 0, quietAfterMs: 0, errors: 0, stalls: 0 };
|
|
85
|
+
s.visits += 1;
|
|
86
|
+
s.requests += v.requests;
|
|
87
|
+
s.requestMs += v.requestMs;
|
|
88
|
+
s.quietAfterMs += v.quietAfterMs;
|
|
89
|
+
s.errors += v.errors;
|
|
90
|
+
s.stalls += v.stalls;
|
|
91
|
+
if (v.slowest && (!s.slowest || v.slowest.ms > s.slowest.ms)) s.slowest = v.slowest;
|
|
92
|
+
by.set(v.route, s);
|
|
93
|
+
}
|
|
94
|
+
return [...by.values()]
|
|
95
|
+
.map((s) => ({
|
|
96
|
+
...s,
|
|
97
|
+
requests: Math.round(s.requests / s.visits),
|
|
98
|
+
requestMs: Math.round(s.requestMs / s.visits),
|
|
99
|
+
quietAfterMs: Math.round(s.quietAfterMs / s.visits),
|
|
100
|
+
}))
|
|
101
|
+
.sort((a, b) => b.quietAfterMs - a.quietAfterMs || b.requests - a.requests);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const describePerf = (summary: RouteSummary[]): string => {
|
|
105
|
+
if (summary.length === 0) return '(no screen visits in the trace yet)';
|
|
106
|
+
const lines = ['route visits req/visit req ms quiet after errors stalls slowest'];
|
|
107
|
+
for (const s of summary) {
|
|
108
|
+
const slow = s.slowest ? `${s.slowest.method} ${s.slowest.path.split('?')[0]} ${s.slowest.ms}ms` : '';
|
|
109
|
+
lines.push(
|
|
110
|
+
`${s.route.padEnd(34)} ${String(s.visits).padStart(6)} ${String(s.requests).padStart(9)} ${String(s.requestMs).padStart(6)} ${String(s.quietAfterMs).padStart(9)}ms ${String(s.errors).padStart(6)} ${String(s.stalls).padStart(6)} ${slow}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return lines.join('\n');
|
|
114
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directions: the shortest known way from one place to another, as the
|
|
3
|
+
* controls to press. A place is a route, or a layer open over one
|
|
4
|
+
* (`Settings.Main#account-button`), named after the control that opened it
|
|
5
|
+
* because pressing that control is the only way back in. Observed edges are
|
|
6
|
+
* tried before static ones.
|
|
7
|
+
*/
|
|
8
|
+
import { edgeFrom, edgeTo, type ObservedEdge, splitNode } from './edges.js';
|
|
9
|
+
import { vocabularyOf } from './query.js';
|
|
10
|
+
import type { ScreenGraph } from './types.js';
|
|
11
|
+
|
|
12
|
+
export type PlanStep = {
|
|
13
|
+
from: string;
|
|
14
|
+
to: string;
|
|
15
|
+
/** The testID to press; without one, a hint for whoever looks at the screen. */
|
|
16
|
+
press?: string;
|
|
17
|
+
hint?: string;
|
|
18
|
+
/** The step leaves a sheet or dialog open rather than changing route. */
|
|
19
|
+
opens?: string;
|
|
20
|
+
source: 'observed' | 'static';
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Move = { to: string; press?: string; hint?: string; opens?: string; source: 'observed' | 'static' };
|
|
24
|
+
|
|
25
|
+
const movesFrom = (graph: ScreenGraph, node: string, observed: ObservedEdge[]): Move[] => {
|
|
26
|
+
const moves: Move[] = observed.map((e) => ({
|
|
27
|
+
to: edgeTo(e),
|
|
28
|
+
press: e.via.startsWith('text:') ? undefined : e.via,
|
|
29
|
+
hint: e.via.startsWith('text:') ? e.via : undefined,
|
|
30
|
+
opens: e.toLayer !== e.fromLayer ? e.toLayer : undefined,
|
|
31
|
+
source: 'observed',
|
|
32
|
+
}));
|
|
33
|
+
// Source knows routes, not what a route has open: a static edge only applies where nothing covers the screen.
|
|
34
|
+
const { route, layer } = splitNode(node);
|
|
35
|
+
if (layer !== undefined) return moves;
|
|
36
|
+
const vocabulary = vocabularyOf(graph, route, 1);
|
|
37
|
+
for (const edge of vocabulary?.navigatesTo ?? []) {
|
|
38
|
+
if (edge.confidence !== 'static' || !graph.screens[edge.to]) continue;
|
|
39
|
+
if (moves.some((m) => m.to === edge.to)) continue;
|
|
40
|
+
moves.push({
|
|
41
|
+
to: edge.to,
|
|
42
|
+
press: edge.via.kind === 'testId' ? edge.via.testId : undefined,
|
|
43
|
+
hint:
|
|
44
|
+
edge.via.kind === 'dynamicTestId'
|
|
45
|
+
? `testId built at runtime: ${edge.via.expression}`
|
|
46
|
+
: edge.via.kind === 'unknown'
|
|
47
|
+
? `navigated from ${edge.from}:${edge.line} without a visible trigger`
|
|
48
|
+
: undefined,
|
|
49
|
+
source: 'static',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return moves;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Breadth-first over places. A bare route as the destination accepts arriving with something open over it. */
|
|
56
|
+
export const plan = (graph: ScreenGraph, from: string, to: string, observed: ObservedEdge[] = [], maxDepth = 6): PlanStep[] | null => {
|
|
57
|
+
const byNode = new Map<string, ObservedEdge[]>();
|
|
58
|
+
for (const e of observed) {
|
|
59
|
+
const list = byNode.get(edgeFrom(e));
|
|
60
|
+
if (list) list.push(e);
|
|
61
|
+
else byNode.set(edgeFrom(e), [e]);
|
|
62
|
+
}
|
|
63
|
+
const arrived = (node: string) => node === to || splitNode(node).route === to;
|
|
64
|
+
if (arrived(from)) return [];
|
|
65
|
+
const previous = new Map<string, PlanStep>();
|
|
66
|
+
const queue: string[] = [from];
|
|
67
|
+
const depth = new Map<string, number>([[from, 0]]);
|
|
68
|
+
while (queue.length) {
|
|
69
|
+
const node = queue.shift()!;
|
|
70
|
+
const d = depth.get(node) ?? 0;
|
|
71
|
+
if (d >= maxDepth) continue;
|
|
72
|
+
for (const move of movesFrom(graph, node, byNode.get(node) ?? [])) {
|
|
73
|
+
if (previous.has(move.to) || move.to === from) continue;
|
|
74
|
+
previous.set(move.to, { from: node, to: move.to, press: move.press, hint: move.hint, opens: move.opens, source: move.source });
|
|
75
|
+
depth.set(move.to, d + 1);
|
|
76
|
+
if (arrived(move.to)) {
|
|
77
|
+
const steps: PlanStep[] = [];
|
|
78
|
+
for (let at: string | undefined = move.to; at && at !== from; at = previous.get(at)?.from) {
|
|
79
|
+
steps.unshift(previous.get(at)!);
|
|
80
|
+
}
|
|
81
|
+
return steps;
|
|
82
|
+
}
|
|
83
|
+
queue.push(move.to);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const describePlan = (steps: PlanStep[]): string =>
|
|
90
|
+
steps
|
|
91
|
+
.map(
|
|
92
|
+
(s, i) =>
|
|
93
|
+
`${i + 1}. ${s.from} → ${s.to} ${s.press ? `press #${s.press}` : `(${s.hint ?? 'look at the screen'})`}${s.opens ? ' (opens a layer)' : ''} [${s.source}]`,
|
|
94
|
+
)
|
|
95
|
+
.join('\n');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RequestRecord, Screen } from 'lynkeus-protocol';
|
|
2
|
+
|
|
3
|
+
export const withoutHost = (url: string) => url.replace(/^https?:\/\/[^/]+/, '');
|
|
4
|
+
|
|
5
|
+
export const pathOf = (url: string) => withoutHost(url).split('?')[0]!;
|
|
6
|
+
|
|
7
|
+
/** What a person would call broken on a screen: nothing on it, the app's own error copy, a request that failed. */
|
|
8
|
+
export const problemsOf = (screen: Screen, requests: RequestRecord[], errorCopy?: RegExp): string[] => [
|
|
9
|
+
...(screen.elements.length === 0 ? ['blank screen'] : []),
|
|
10
|
+
...screen.elements.flatMap((e) => (errorCopy && e.kind === 'text' && e.text && errorCopy.test(e.text) ? [`copy: ${e.text.slice(0, 80)}`] : [])),
|
|
11
|
+
...requests.filter((r) => (r.status ?? 0) >= 500 || r.error).map((r) => `${r.status ?? 'ERR'} ${r.method} ${withoutHost(r.url)}`),
|
|
12
|
+
];
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { edgeFrom, type ObservedEdge } from './edges.js';
|
|
2
|
+
import type { NavigationEdge, RouteName, ScreenGraph, Selector, SourcePath } from './types.js';
|
|
3
|
+
|
|
4
|
+
// Bounded: through barrels an unbounded closure reaches most of the app and makes every screen look the same.
|
|
5
|
+
const closureOf = (graph: ScreenGraph, entry: SourcePath, maxDepth = 2): Set<SourcePath> => {
|
|
6
|
+
const seen = new Set<SourcePath>([entry]);
|
|
7
|
+
let frontier: SourcePath[] = [entry];
|
|
8
|
+
|
|
9
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
10
|
+
const next: SourcePath[] = [];
|
|
11
|
+
for (const file of frontier) {
|
|
12
|
+
for (const imported of graph.files[file]?.imports ?? []) {
|
|
13
|
+
if (seen.has(imported)) continue;
|
|
14
|
+
seen.add(imported);
|
|
15
|
+
next.push(imported);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (next.length === 0) break;
|
|
19
|
+
frontier = next;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return seen;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type Vocabulary = {
|
|
26
|
+
route: RouteName;
|
|
27
|
+
file: SourcePath | null;
|
|
28
|
+
selectors: (Selector & { from: SourcePath })[];
|
|
29
|
+
navigatesTo: (NavigationEdge & { from: SourcePath })[];
|
|
30
|
+
filesConsidered: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A weak guess at what a screen offers, from its file and what it imports
|
|
35
|
+
* directly. One hop only: through barrels the whole component library is two
|
|
36
|
+
* hops from every screen. The running app is the ground truth.
|
|
37
|
+
*/
|
|
38
|
+
export const vocabularyOf = (graph: ScreenGraph, route: RouteName, maxDepth = 1): Vocabulary | null => {
|
|
39
|
+
const screen = graph.screens[route];
|
|
40
|
+
if (!screen) return null;
|
|
41
|
+
if (!screen.file) {
|
|
42
|
+
return { route, file: null, selectors: [], navigatesTo: [], filesConsidered: 0 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const closure = closureOf(graph, screen.file, maxDepth);
|
|
46
|
+
const selectors: (Selector & { from: SourcePath })[] = [];
|
|
47
|
+
const navigatesTo: (NavigationEdge & { from: SourcePath })[] = [];
|
|
48
|
+
|
|
49
|
+
for (const file of closure) {
|
|
50
|
+
const facts = graph.files[file];
|
|
51
|
+
if (!facts) continue;
|
|
52
|
+
for (const selector of facts.selectors) selectors.push({ ...selector, from: file });
|
|
53
|
+
for (const edge of facts.navigates) navigatesTo.push({ ...edge, from: file });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { route, file: screen.file, selectors, navigatesTo, filesConsidered: closure.size };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type Approach = {
|
|
60
|
+
via: string;
|
|
61
|
+
source: 'static' | 'observed';
|
|
62
|
+
/** The file that declares it, or the place it was walked from. */
|
|
63
|
+
from: string;
|
|
64
|
+
seen?: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The controls that lead to a route, the walked ones first: they beat an inference from source. */
|
|
68
|
+
export const pathsTo = (graph: ScreenGraph, destination: RouteName, observed: ObservedEdge[] = []): Approach[] => {
|
|
69
|
+
const approaches: Approach[] = observed
|
|
70
|
+
.filter((edge) => edge.to === destination)
|
|
71
|
+
.map((edge) => ({
|
|
72
|
+
via: edge.via,
|
|
73
|
+
source: 'observed',
|
|
74
|
+
from: edgeFrom(edge),
|
|
75
|
+
seen: edge.seen,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
for (const [file, facts] of Object.entries(graph.files)) {
|
|
79
|
+
for (const edge of facts.navigates) {
|
|
80
|
+
if (edge.to !== destination || edge.confidence !== 'static') continue;
|
|
81
|
+
if (edge.via.kind === 'unknown') continue;
|
|
82
|
+
|
|
83
|
+
const via = edge.via.kind === 'testId' ? edge.via.testId : `<runtime: ${edge.via.expression}>`;
|
|
84
|
+
if (approaches.some((a) => a.via === via)) continue;
|
|
85
|
+
approaches.push({ via, source: 'static', from: file });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return approaches;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type Affected = { route: RouteName; distance: number; via: SourcePath; through: SourcePath[] };
|
|
93
|
+
|
|
94
|
+
/** Screens a change can reach, nearest first; `distance` is how many imports separate the screen's file from a changed one. */
|
|
95
|
+
export const screensAffectedBy = (graph: ScreenGraph, changedFiles: SourcePath[], maxDepth = 4): Affected[] => {
|
|
96
|
+
const importedBy = new Map<SourcePath, SourcePath[]>();
|
|
97
|
+
for (const [file, facts] of Object.entries(graph.files)) {
|
|
98
|
+
for (const target of facts.imports) {
|
|
99
|
+
const list = importedBy.get(target);
|
|
100
|
+
if (list) list.push(file);
|
|
101
|
+
else importedBy.set(target, [file]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const throughBarrel = new Map<SourcePath, SourcePath[]>();
|
|
106
|
+
for (const [file, facts] of Object.entries(graph.files)) {
|
|
107
|
+
for (const barrel of facts.barrels ?? []) {
|
|
108
|
+
const list = throughBarrel.get(barrel);
|
|
109
|
+
if (list) list.push(file);
|
|
110
|
+
else throughBarrel.set(barrel, [file]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type Hop = { distance: number; via: SourcePath; through: SourcePath[] };
|
|
115
|
+
const reached = new Map<SourcePath, Hop>();
|
|
116
|
+
let frontier: SourcePath[] = [];
|
|
117
|
+
for (const f of changedFiles) {
|
|
118
|
+
if (!reached.has(f)) {
|
|
119
|
+
reached.set(f, { distance: 0, via: f, through: [] });
|
|
120
|
+
frontier.push(f);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
|
124
|
+
const next: SourcePath[] = [];
|
|
125
|
+
for (const file of frontier) {
|
|
126
|
+
const hop = reached.get(file)!;
|
|
127
|
+
// Taking a name through a barrel depends on the barrel only when the barrel itself changed.
|
|
128
|
+
const dependents = hop.distance === 0 ? [...(importedBy.get(file) ?? []), ...(throughBarrel.get(file) ?? [])] : (importedBy.get(file) ?? []);
|
|
129
|
+
for (const dependent of dependents) {
|
|
130
|
+
if (reached.has(dependent)) continue;
|
|
131
|
+
reached.set(dependent, { distance: depth, via: hop.via, through: [...hop.through, file] });
|
|
132
|
+
next.push(dependent);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
frontier = next;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const affected: Affected[] = [];
|
|
139
|
+
for (const screen of Object.values(graph.screens)) {
|
|
140
|
+
if (!screen.file) continue;
|
|
141
|
+
const hop = reached.get(screen.file);
|
|
142
|
+
if (!hop) continue;
|
|
143
|
+
affected.push({ route: screen.route, distance: hop.distance, via: hop.via, through: hop.through.slice(1) });
|
|
144
|
+
}
|
|
145
|
+
return affected.sort((a, b) => a.distance - b.distance || a.route.localeCompare(b.route));
|
|
146
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which routes open without parameters, read from the app's React Navigation
|
|
3
|
+
* `ParamList` type: `undefined`, or an object whose keys are all optional.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export type RouteParamsInfo = { route: string; free: boolean; shape: string };
|
|
9
|
+
|
|
10
|
+
export const readRouteParams = (root: string, paramsFile = 'app/navigation/types.d.ts'): RouteParamsInfo[] => {
|
|
11
|
+
const file = path.join(root, paramsFile);
|
|
12
|
+
if (!fs.existsSync(file)) return [];
|
|
13
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
14
|
+
const start = Math.max(0, text.search(/\b\w*ParamList\b|ScreenRouteParams/));
|
|
15
|
+
const out = new Map<string, RouteParamsInfo>();
|
|
16
|
+
const entry = /'([A-Z][\w.]+)':\s*/g;
|
|
17
|
+
entry.lastIndex = start;
|
|
18
|
+
for (const match of text.matchAll(entry)) {
|
|
19
|
+
const route = match[1]!;
|
|
20
|
+
let i = match.index + match[0].length;
|
|
21
|
+
let shape = '';
|
|
22
|
+
if (text.startsWith('undefined', i)) {
|
|
23
|
+
shape = 'undefined';
|
|
24
|
+
} else if (text[i] === '{') {
|
|
25
|
+
let depth = 0;
|
|
26
|
+
const from = i;
|
|
27
|
+
for (; i < text.length; i++) {
|
|
28
|
+
if (text[i] === '{') depth++;
|
|
29
|
+
else if (text[i] === '}' && --depth === 0) break;
|
|
30
|
+
}
|
|
31
|
+
shape = text.slice(from, i + 1);
|
|
32
|
+
} else {
|
|
33
|
+
shape = text.slice(i, text.indexOf('\n', i)).trim();
|
|
34
|
+
}
|
|
35
|
+
const free = shape === 'undefined' || (shape.startsWith('{') && !/^\s*\w+\s*:/m.test(shape.slice(1, -1).replace(/\{[^}]*\}/g, '')));
|
|
36
|
+
if (!out.has(route)) out.set(route, { route, free, shape: shape.replace(/\s+/g, ' ').slice(0, 120) });
|
|
37
|
+
}
|
|
38
|
+
return [...out.values()];
|
|
39
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structure of each route the last time it was read, under
|
|
3
|
+
* .lynkeus/screens/, and in changes.jsonl every testID that appeared,
|
|
4
|
+
* disappeared or moved since: a suite that never asserts on an element still
|
|
5
|
+
* tells you the day it vanished.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import type { Screen } from 'lynkeus-protocol';
|
|
11
|
+
|
|
12
|
+
import { diffScreens } from '../flow/artifacts.js';
|
|
13
|
+
import { appendJsonl, readJsonl, today } from '../files.js';
|
|
14
|
+
|
|
15
|
+
type Frame = { x: number; y: number; w: number; h: number };
|
|
16
|
+
type Stored = { route: string; path: string[]; seen: number; date: string; elements: Screen['elements']; frames?: Record<string, Frame> };
|
|
17
|
+
|
|
18
|
+
export type ScreenChange = { route: string; date: string; added: string[]; removed: string[]; changedText: string[]; moved?: string[] };
|
|
19
|
+
|
|
20
|
+
// Sub-pixel rounding and font hinting move an element a point or two without any layout change.
|
|
21
|
+
const GEOMETRY_TOLERANCE = 4;
|
|
22
|
+
|
|
23
|
+
const presented = (e: Screen['elements'][number]) => !e.hidden || e.hidden === 'inert';
|
|
24
|
+
|
|
25
|
+
const framesOf = (screen: Screen): Record<string, Frame> => {
|
|
26
|
+
const out: Record<string, Frame> = {};
|
|
27
|
+
for (const e of screen.elements.filter(presented)) {
|
|
28
|
+
if (!e.testId || !e.frame || out[e.testId]) continue;
|
|
29
|
+
out[e.testId] = { x: Math.round(e.frame.x), y: Math.round(e.frame.y), w: Math.round(e.frame.w), h: Math.round(e.frame.h) };
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const diffGeometry = (before: Record<string, Frame>, after: Record<string, Frame>, tolerance = GEOMETRY_TOLERANCE): string[] => {
|
|
35
|
+
const lines: string[] = [];
|
|
36
|
+
for (const [id, a] of Object.entries(after)) {
|
|
37
|
+
const b = before[id];
|
|
38
|
+
if (!b) continue;
|
|
39
|
+
const moved = Math.abs(a.x - b.x) > tolerance || Math.abs(a.y - b.y) > tolerance;
|
|
40
|
+
const resized = Math.abs(a.w - b.w) > tolerance || Math.abs(a.h - b.h) > tolerance;
|
|
41
|
+
const collapsed = (a.w === 0 || a.h === 0) && b.w > 0 && b.h > 0;
|
|
42
|
+
if (collapsed) lines.push(`#${id} collapsed to ${a.w}×${a.h} (was ${b.w}×${b.h})`);
|
|
43
|
+
else if (moved && resized) lines.push(`#${id} moved (${b.x},${b.y})→(${a.x},${a.y}) and resized ${b.w}×${b.h}→${a.w}×${a.h}`);
|
|
44
|
+
else if (moved) lines.push(`#${id} moved (${b.x},${b.y})→(${a.x},${a.y})`);
|
|
45
|
+
else if (resized) lines.push(`#${id} resized ${b.w}×${b.h}→${a.w}×${a.h}`);
|
|
46
|
+
}
|
|
47
|
+
return lines;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const dirOf = (lynkeusDir: string) => path.join(lynkeusDir, 'screens');
|
|
51
|
+
const fileOf = (lynkeusDir: string, route: string) => path.join(dirOf(lynkeusDir), `${route.replace(/[^\w.-]/g, '_')}.json`);
|
|
52
|
+
const changesFile = (lynkeusDir: string) => path.join(dirOf(lynkeusDir), 'changes.jsonl');
|
|
53
|
+
|
|
54
|
+
const strip = (screen: Screen): Screen['elements'] =>
|
|
55
|
+
screen.elements.filter(presented).map((e) => {
|
|
56
|
+
const { frame: _frame, ...rest } = e as Screen['elements'][number] & { frame?: unknown };
|
|
57
|
+
return rest as Screen['elements'][number];
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const label = (e: Screen['elements'][number]) => `${e.kind}${e.testId ? ` #${e.testId}` : ''}${e.text ? ` ${JSON.stringify(e.text.slice(0, 60))}` : ''}`;
|
|
61
|
+
|
|
62
|
+
/** Stores the screen under its route and returns what changed since the previous read. */
|
|
63
|
+
export const record = (lynkeusDir: string, screen: Screen): ScreenChange | null => {
|
|
64
|
+
if (!screen.route) return null;
|
|
65
|
+
fs.mkdirSync(dirOf(lynkeusDir), { recursive: true });
|
|
66
|
+
const file = fileOf(lynkeusDir, screen.route);
|
|
67
|
+
const date = today();
|
|
68
|
+
const elements = strip(screen);
|
|
69
|
+
let previous: Stored | null = null;
|
|
70
|
+
try {
|
|
71
|
+
previous = JSON.parse(fs.readFileSync(file, 'utf8')) as Stored;
|
|
72
|
+
} catch {}
|
|
73
|
+
const frames = framesOf(screen);
|
|
74
|
+
const next: Stored = { route: screen.route, path: screen.path, seen: (previous?.seen ?? 0) + 1, date, elements, frames };
|
|
75
|
+
fs.writeFileSync(file, JSON.stringify(next, null, 1));
|
|
76
|
+
if (!previous) return null;
|
|
77
|
+
const diff = diffScreens({ ...screen, elements: previous.elements }, { ...screen, elements });
|
|
78
|
+
// Texts carry values that change every run; only a testID appearing or disappearing is worth a line.
|
|
79
|
+
const added = diff.added.filter((e) => e.testId).map(label);
|
|
80
|
+
const removed = diff.removed.filter((e) => e.testId).map(label);
|
|
81
|
+
const moved = previous.frames ? diffGeometry(previous.frames, frames) : [];
|
|
82
|
+
if (added.length === 0 && removed.length === 0 && moved.length === 0) return null;
|
|
83
|
+
const change: ScreenChange = {
|
|
84
|
+
route: screen.route,
|
|
85
|
+
date,
|
|
86
|
+
added,
|
|
87
|
+
removed,
|
|
88
|
+
moved,
|
|
89
|
+
changedText: diff.changedText.map((c) => `#${c.after.testId}: ${JSON.stringify(c.before.text ?? '')} → ${JSON.stringify(c.after.text ?? '')}`).slice(0, 10),
|
|
90
|
+
};
|
|
91
|
+
appendJsonl(changesFile(lynkeusDir), change);
|
|
92
|
+
return change;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type Observed = {
|
|
96
|
+
route: string;
|
|
97
|
+
path: string[];
|
|
98
|
+
seen: number;
|
|
99
|
+
date: string;
|
|
100
|
+
testIds: string[];
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** What runs have seen, by route: the routes and interpolated testIDs a static parser cannot find. */
|
|
104
|
+
export const observed = (lynkeusDir: string): Map<string, Observed> => {
|
|
105
|
+
const out = new Map<string, Observed>();
|
|
106
|
+
let files: string[] = [];
|
|
107
|
+
try {
|
|
108
|
+
files = fs.readdirSync(dirOf(lynkeusDir)).filter((f) => f.endsWith('.json'));
|
|
109
|
+
} catch {
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
let stored: Stored;
|
|
114
|
+
try {
|
|
115
|
+
stored = JSON.parse(fs.readFileSync(path.join(dirOf(lynkeusDir), file), 'utf8')) as Stored;
|
|
116
|
+
} catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!stored.route) continue;
|
|
120
|
+
const testIds: string[] = [];
|
|
121
|
+
for (const e of stored.elements ?? []) {
|
|
122
|
+
if (!e.testId || e.hidden) continue;
|
|
123
|
+
if (!testIds.includes(e.testId)) testIds.push(e.testId);
|
|
124
|
+
}
|
|
125
|
+
out.set(stored.route, { route: stored.route, path: stored.path ?? [], seen: stored.seen ?? 1, date: stored.date, testIds });
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const changes = (lynkeusDir: string, last = 50): ScreenChange[] => readJsonl<ScreenChange>(changesFile(lynkeusDir)).slice(-last);
|
|
131
|
+
|
|
132
|
+
export const describeChange = (c: ScreenChange): string =>
|
|
133
|
+
[
|
|
134
|
+
`${c.date} ${c.route}`,
|
|
135
|
+
...c.added.map((a) => ` + ${a}`),
|
|
136
|
+
...c.removed.map((r) => ` - ${r}`),
|
|
137
|
+
...(c.moved ?? []).map((m) => ` ⇢ ${m}`),
|
|
138
|
+
...c.changedText.map((t) => ` ~ ${t}`),
|
|
139
|
+
].join('\n');
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The screen as nested lines of meaning (kinds, labels, testIDs, values,
|
|
3
|
+
* states) and no geometry: a spacing tweak changes nothing, a renamed heading
|
|
4
|
+
* or a lost button is a changed line. Diffed like code.
|
|
5
|
+
*/
|
|
6
|
+
import type { Element, Screen } from 'lynkeus-protocol';
|
|
7
|
+
|
|
8
|
+
const quote = (s: string) => JSON.stringify(s);
|
|
9
|
+
|
|
10
|
+
const node = (e: Element): string => {
|
|
11
|
+
const parts: string[] = [e.kind];
|
|
12
|
+
const label = e.text ?? e.accessibilityLabel;
|
|
13
|
+
if (label) parts.push(quote(label.length > 80 ? `${label.slice(0, 77)}…` : label));
|
|
14
|
+
if (e.testId) parts.push(`#${e.testId}`);
|
|
15
|
+
if (e.value !== undefined && e.value !== '') parts.push(`value=${quote(e.value)}`);
|
|
16
|
+
if (e.placeholder) parts.push(`placeholder=${quote(e.placeholder)}`);
|
|
17
|
+
const state = [!e.enabled ? 'disabled' : '', e.covered ? 'covered' : '', e.hidden ?? '', e.focused ? 'focused' : ''].filter(Boolean);
|
|
18
|
+
if (state.length) parts.push(`[${state.join(' ')}]`);
|
|
19
|
+
return parts.join(' ');
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const meaningful = (e: Element): boolean => e.kind !== 'view' || e.accessibilityLabel !== undefined || e.testId !== undefined;
|
|
23
|
+
|
|
24
|
+
export const screenTree = (screen: Screen): string => {
|
|
25
|
+
const lines = [`- screen ${screen.route ?? '?'}${screen.path.length > 1 ? ` (${screen.path.join(' > ')})` : ''}`];
|
|
26
|
+
// Relative to the nearest listed ancestor, so the layout views left out leave no gaps.
|
|
27
|
+
const level = new Map<number, number>();
|
|
28
|
+
for (const e of screen.elements) {
|
|
29
|
+
const parentLevel = e.parent !== undefined && e.parent >= 0 ? (level.get(e.parent) ?? 0) : 0;
|
|
30
|
+
if (!meaningful(e)) {
|
|
31
|
+
level.set(e.i, parentLevel);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const mine = parentLevel + 1;
|
|
35
|
+
level.set(e.i, mine);
|
|
36
|
+
lines.push(`${' '.repeat(mine)}- ${node(e)}`);
|
|
37
|
+
}
|
|
38
|
+
return `${lines.join('\n')}\n`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type TreeDiff = { added: string[]; removed: string[]; same: number };
|
|
42
|
+
|
|
43
|
+
/** The lines only one side has. */
|
|
44
|
+
export const diffTrees = (before: string, after: string): TreeDiff => {
|
|
45
|
+
const a = before.split('\n').filter(Boolean);
|
|
46
|
+
const b = after.split('\n').filter(Boolean);
|
|
47
|
+
const countA = new Map<string, number>();
|
|
48
|
+
for (const l of a) countA.set(l, (countA.get(l) ?? 0) + 1);
|
|
49
|
+
const added: string[] = [];
|
|
50
|
+
let same = 0;
|
|
51
|
+
for (const l of b) {
|
|
52
|
+
const n = countA.get(l) ?? 0;
|
|
53
|
+
if (n > 0) {
|
|
54
|
+
countA.set(l, n - 1);
|
|
55
|
+
same += 1;
|
|
56
|
+
} else added.push(l);
|
|
57
|
+
}
|
|
58
|
+
const removed: string[] = [];
|
|
59
|
+
for (const [l, n] of countA) for (let i = 0; i < n; i++) removed.push(l);
|
|
60
|
+
return { added, removed, same };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const describeTreeDiff = (d: TreeDiff): string => {
|
|
64
|
+
if (!d.added.length && !d.removed.length) return ' (the tree did not change)';
|
|
65
|
+
return [...d.removed.map((l) => `- ${l.trim()}`), ...d.added.map((l) => `+ ${l.trim()}`)].map((l) => ` ${l}`).join('\n');
|
|
66
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What lynkeus reads from an app's source. Facts are kept per file and
|
|
3
|
+
* attributed to screens by traversal: most testIDs and navigations live in
|
|
4
|
+
* shared components, so attributing them at extraction would always miss some.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** As declared to the navigator, e.g. `Settings.Profile`. */
|
|
8
|
+
export type RouteName = string;
|
|
9
|
+
|
|
10
|
+
/** Relative to the app's root. */
|
|
11
|
+
export type SourcePath = string;
|
|
12
|
+
|
|
13
|
+
/** A testID written as a literal; one built at runtime is only found on the device. */
|
|
14
|
+
export type Selector = { id: string; line: number };
|
|
15
|
+
|
|
16
|
+
/** What the user touches to cause a navigation. */
|
|
17
|
+
export type NavigationTrigger =
|
|
18
|
+
| { kind: 'testId'; testId: string }
|
|
19
|
+
/** The element has a testID built at runtime. */
|
|
20
|
+
| { kind: 'dynamicTestId'; expression: string }
|
|
21
|
+
/** Nothing to attribute it to: conditional routing, effects, deep links. */
|
|
22
|
+
| { kind: 'unknown' };
|
|
23
|
+
|
|
24
|
+
export type NavigationEdge = {
|
|
25
|
+
/** The route when it is a literal, otherwise the source expression. */
|
|
26
|
+
to: string;
|
|
27
|
+
line: number;
|
|
28
|
+
confidence: 'static' | 'dynamic';
|
|
29
|
+
via: NavigationTrigger;
|
|
30
|
+
/** `element` when the call sits in the element's own handler, `handler` when it was reached through a named function. */
|
|
31
|
+
linkage?: 'element' | 'handler';
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type FileFacts = {
|
|
35
|
+
selectors: Selector[];
|
|
36
|
+
navigates: NavigationEdge[];
|
|
37
|
+
/** First-party imports; a name imported through a barrel points at the file that defines it. */
|
|
38
|
+
imports: SourcePath[];
|
|
39
|
+
/** The barrels those names came through: a dependency only when the barrel itself changes. */
|
|
40
|
+
barrels?: SourcePath[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ScreenNode = {
|
|
44
|
+
route: RouteName;
|
|
45
|
+
/** The component bound in the navigator. */
|
|
46
|
+
component: string;
|
|
47
|
+
/** Null when the component could not be resolved to a file. */
|
|
48
|
+
file: SourcePath | null;
|
|
49
|
+
declaredIn: SourcePath;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type ScreenGraph = {
|
|
53
|
+
app: string;
|
|
54
|
+
root: string;
|
|
55
|
+
extractedAt: string;
|
|
56
|
+
screens: Record<RouteName, ScreenNode>;
|
|
57
|
+
files: Record<SourcePath, FileFacts>;
|
|
58
|
+
/** What could not be read, so the graph is never silently wrong. */
|
|
59
|
+
warnings: string[];
|
|
60
|
+
};
|