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,89 @@
|
|
|
1
|
+
const DEFAULT_I18N_KEY_PATTERNS = [/^[a-z][a-z0-9]*(\.[a-z0-9]+){2,}$/i, /^[a-z0-9]+(_[a-z0-9]+){2,}$/i];
|
|
2
|
+
const looksLikeI18nKey = (t, patterns) => {
|
|
3
|
+
const s = t.trim();
|
|
4
|
+
if (patterns.length === 0)
|
|
5
|
+
return false;
|
|
6
|
+
if (s.length < 4 || /\s/.test(s))
|
|
7
|
+
return false;
|
|
8
|
+
if (/^https?:|^[@#$]|^\+?\d[\d\s.,-]*$/.test(s))
|
|
9
|
+
return false;
|
|
10
|
+
return patterns.some((p) => p.test(s));
|
|
11
|
+
};
|
|
12
|
+
// Points: what both platforms' guidelines ask for.
|
|
13
|
+
const MIN_TOUCH = 44;
|
|
14
|
+
const label = (e) => e.testId ?? e.text ?? e.accessibilityLabel;
|
|
15
|
+
export const lintScreen = (screen, options = {}) => {
|
|
16
|
+
const minTouch = options.minTouchTarget ?? MIN_TOUCH;
|
|
17
|
+
const out = [];
|
|
18
|
+
const els = (screen.elements ?? []).filter((e) => !e.hidden);
|
|
19
|
+
const win = screen.window ?? { w: 0, h: 0 };
|
|
20
|
+
for (const e of els) {
|
|
21
|
+
const named = e.text ?? e.accessibilityLabel;
|
|
22
|
+
if ((e.kind === 'button' || e.kind === 'switch') && named === undefined && e.testId === undefined) {
|
|
23
|
+
out.push({ rule: 'unlabeled-control', severity: 'warn', element: e.i, message: `${e.kind} with no text, label or testId` });
|
|
24
|
+
}
|
|
25
|
+
if ((e.kind === 'button' || e.kind === 'switch') && e.enabled !== false && e.frame) {
|
|
26
|
+
const { w, h } = e.frame;
|
|
27
|
+
// A control with text is usually measured at its label, not its touchable area (a tab bar item): only info.
|
|
28
|
+
if (w > 0 && h > 0 && (w < minTouch || h < minTouch)) {
|
|
29
|
+
const iconOnly = e.text === undefined && e.accessibilityLabel === undefined;
|
|
30
|
+
out.push({
|
|
31
|
+
rule: 'small-target',
|
|
32
|
+
severity: iconOnly ? 'warn' : 'info',
|
|
33
|
+
element: e.i,
|
|
34
|
+
message: `${Math.round(w)}×${Math.round(h)}pt target (< ${minTouch}) ${label(e) ?? ''}`.trim(),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (e.kind === 'text' && e.text && looksLikeI18nKey(e.text, options.i18nKeyPatterns ?? DEFAULT_I18N_KEY_PATTERNS)) {
|
|
39
|
+
out.push({ rule: 'untranslated', severity: 'warn', element: e.i, message: `"${e.text}" looks like an i18n key, not copy` });
|
|
40
|
+
}
|
|
41
|
+
if ((e.kind === 'button' || e.kind === 'input' || e.kind === 'switch') && e.covered && e.enabled !== false) {
|
|
42
|
+
out.push({
|
|
43
|
+
rule: 'covered-control',
|
|
44
|
+
severity: 'info',
|
|
45
|
+
element: e.i,
|
|
46
|
+
message: `${e.kind} ${label(e) ?? ''} is covered; a tap would not reach it`.replace(' ', ' ').trim(),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (e.frame && win.w > 0 && label(e) !== undefined) {
|
|
50
|
+
const { x, y, w, h } = e.frame;
|
|
51
|
+
if (x + w > win.w + 1 || y + h > win.h + 1 || x < -1 || y < -1) {
|
|
52
|
+
out.push({
|
|
53
|
+
rule: 'offscreen',
|
|
54
|
+
severity: 'info',
|
|
55
|
+
element: e.i,
|
|
56
|
+
message: `${label(e)} extends past the ${win.w}×${win.h} window (x${Math.round(x)} y${Math.round(y)} ${Math.round(w)}×${Math.round(h)})`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (e.text && e.frame && (e.frame.w === 0 || e.frame.h === 0)) {
|
|
61
|
+
out.push({
|
|
62
|
+
rule: 'zero-size',
|
|
63
|
+
severity: 'info',
|
|
64
|
+
element: e.i,
|
|
65
|
+
message: `"${e.text.slice(0, 30)}" has ${Math.round(e.frame.w)}×${Math.round(e.frame.h)} size`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const byTestId = new Map();
|
|
70
|
+
for (const e of els) {
|
|
71
|
+
if (!e.testId)
|
|
72
|
+
continue;
|
|
73
|
+
const list = byTestId.get(e.testId) ?? [];
|
|
74
|
+
list.push(e.i);
|
|
75
|
+
byTestId.set(e.testId, list);
|
|
76
|
+
}
|
|
77
|
+
for (const [id, at] of byTestId)
|
|
78
|
+
if (at.length > 1)
|
|
79
|
+
out.push({ rule: 'duplicate-testid', severity: 'info', element: at[0], message: `testId "${id}" is on ${at.length} elements (${at.join(', ')})` });
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
export const describeFindings = (findings, route) => {
|
|
83
|
+
if (findings.length === 0)
|
|
84
|
+
return `${route ?? 'screen'}: clean`;
|
|
85
|
+
const order = { warn: 0, info: 1 };
|
|
86
|
+
const sorted = [...findings].sort((a, b) => order[a.severity] - order[b.severity]);
|
|
87
|
+
const head = `${route ?? 'screen'}: ${findings.filter((f) => f.severity === 'warn').length} warn, ${findings.filter((f) => f.severity === 'info').length} info`;
|
|
88
|
+
return [head, ...sorted.map((f) => ` ${f.severity === 'warn' ? '⚠' : '·'} [${f.rule}] #${f.element} ${f.message}`)].join('\n');
|
|
89
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { TraceEvent } from 'lynkeus-protocol';
|
|
2
|
+
export type Visit = {
|
|
3
|
+
route: string;
|
|
4
|
+
at: number;
|
|
5
|
+
/** How long the screen stayed on top. */
|
|
6
|
+
ms: number;
|
|
7
|
+
requests: number;
|
|
8
|
+
requestMs: number;
|
|
9
|
+
errors: number;
|
|
10
|
+
slowest?: {
|
|
11
|
+
method: string;
|
|
12
|
+
path: string;
|
|
13
|
+
ms: number;
|
|
14
|
+
};
|
|
15
|
+
/** From arriving to the end of the opening burst of requests. */
|
|
16
|
+
quietAfterMs: number;
|
|
17
|
+
stalls: number;
|
|
18
|
+
};
|
|
19
|
+
export declare const visitsFrom: (events: TraceEvent[]) => Visit[];
|
|
20
|
+
export type RouteSummary = {
|
|
21
|
+
route: string;
|
|
22
|
+
visits: number;
|
|
23
|
+
requests: number;
|
|
24
|
+
requestMs: number;
|
|
25
|
+
quietAfterMs: number;
|
|
26
|
+
errors: number;
|
|
27
|
+
stalls: number;
|
|
28
|
+
slowest?: Visit['slowest'];
|
|
29
|
+
};
|
|
30
|
+
/** Per route, averaged over its visits, the costliest arrival first. */
|
|
31
|
+
export declare const summarize: (visits: Visit[]) => RouteSummary[];
|
|
32
|
+
export declare const describePerf: (summary: RouteSummary[]) => string;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const pathOf = (url) => {
|
|
2
|
+
try {
|
|
3
|
+
const u = new URL(url);
|
|
4
|
+
return u.pathname + u.search;
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return url;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
// This long with no request ends the opening burst: later polling does not count against arriving.
|
|
11
|
+
const QUIET_GAP_MS = 1500;
|
|
12
|
+
export const visitsFrom = (events) => {
|
|
13
|
+
const visits = [];
|
|
14
|
+
let current = null;
|
|
15
|
+
let burstEnd = 0;
|
|
16
|
+
let burstOver = false;
|
|
17
|
+
const close = (endT) => {
|
|
18
|
+
if (current)
|
|
19
|
+
current.ms = Math.max(0, endT - current.at);
|
|
20
|
+
};
|
|
21
|
+
for (const e of events) {
|
|
22
|
+
if (e.kind === 'route') {
|
|
23
|
+
close(e.t);
|
|
24
|
+
current = { route: e.route ?? e.path.join('/') ?? '?', at: e.t, ms: 0, requests: 0, requestMs: 0, errors: 0, quietAfterMs: 0, stalls: 0 };
|
|
25
|
+
visits.push(current);
|
|
26
|
+
burstEnd = e.t;
|
|
27
|
+
burstOver = false;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!current)
|
|
31
|
+
continue;
|
|
32
|
+
if (e.kind === 'request') {
|
|
33
|
+
current.requests += 1;
|
|
34
|
+
const ms = e.ms ?? 0;
|
|
35
|
+
current.requestMs += ms;
|
|
36
|
+
if ((e.status ?? 0) >= 400 || e.error)
|
|
37
|
+
current.errors += 1;
|
|
38
|
+
if (!current.slowest || ms > current.slowest.ms)
|
|
39
|
+
current.slowest = { method: e.method, path: pathOf(e.url), ms };
|
|
40
|
+
// A request event is stamped when it ends.
|
|
41
|
+
const start = e.t - ms;
|
|
42
|
+
if (!burstOver && start - burstEnd > QUIET_GAP_MS)
|
|
43
|
+
burstOver = true;
|
|
44
|
+
if (!burstOver) {
|
|
45
|
+
burstEnd = Math.max(burstEnd, e.t);
|
|
46
|
+
current.quietAfterMs = Math.max(current.quietAfterMs, burstEnd - current.at);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else if (e.kind === 'stall') {
|
|
50
|
+
current.stalls += 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const last = events[events.length - 1];
|
|
54
|
+
if (last)
|
|
55
|
+
close(last.t);
|
|
56
|
+
return visits;
|
|
57
|
+
};
|
|
58
|
+
/** Per route, averaged over its visits, the costliest arrival first. */
|
|
59
|
+
export const summarize = (visits) => {
|
|
60
|
+
const by = new Map();
|
|
61
|
+
for (const v of visits) {
|
|
62
|
+
const s = by.get(v.route) ?? { route: v.route, visits: 0, requests: 0, requestMs: 0, quietAfterMs: 0, errors: 0, stalls: 0 };
|
|
63
|
+
s.visits += 1;
|
|
64
|
+
s.requests += v.requests;
|
|
65
|
+
s.requestMs += v.requestMs;
|
|
66
|
+
s.quietAfterMs += v.quietAfterMs;
|
|
67
|
+
s.errors += v.errors;
|
|
68
|
+
s.stalls += v.stalls;
|
|
69
|
+
if (v.slowest && (!s.slowest || v.slowest.ms > s.slowest.ms))
|
|
70
|
+
s.slowest = v.slowest;
|
|
71
|
+
by.set(v.route, s);
|
|
72
|
+
}
|
|
73
|
+
return [...by.values()]
|
|
74
|
+
.map((s) => ({
|
|
75
|
+
...s,
|
|
76
|
+
requests: Math.round(s.requests / s.visits),
|
|
77
|
+
requestMs: Math.round(s.requestMs / s.visits),
|
|
78
|
+
quietAfterMs: Math.round(s.quietAfterMs / s.visits),
|
|
79
|
+
}))
|
|
80
|
+
.sort((a, b) => b.quietAfterMs - a.quietAfterMs || b.requests - a.requests);
|
|
81
|
+
};
|
|
82
|
+
export const describePerf = (summary) => {
|
|
83
|
+
if (summary.length === 0)
|
|
84
|
+
return '(no screen visits in the trace yet)';
|
|
85
|
+
const lines = ['route visits req/visit req ms quiet after errors stalls slowest'];
|
|
86
|
+
for (const s of summary) {
|
|
87
|
+
const slow = s.slowest ? `${s.slowest.method} ${s.slowest.path.split('?')[0]} ${s.slowest.ms}ms` : '';
|
|
88
|
+
lines.push(`${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}`);
|
|
89
|
+
}
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
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 { type ObservedEdge } from './edges.js';
|
|
9
|
+
import type { ScreenGraph } from './types.js';
|
|
10
|
+
export type PlanStep = {
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
/** The testID to press; without one, a hint for whoever looks at the screen. */
|
|
14
|
+
press?: string;
|
|
15
|
+
hint?: string;
|
|
16
|
+
/** The step leaves a sheet or dialog open rather than changing route. */
|
|
17
|
+
opens?: string;
|
|
18
|
+
source: 'observed' | 'static';
|
|
19
|
+
};
|
|
20
|
+
/** Breadth-first over places. A bare route as the destination accepts arriving with something open over it. */
|
|
21
|
+
export declare const plan: (graph: ScreenGraph, from: string, to: string, observed?: ObservedEdge[], maxDepth?: number) => PlanStep[] | null;
|
|
22
|
+
export declare const describePlan: (steps: PlanStep[]) => string;
|
|
@@ -0,0 +1,81 @@
|
|
|
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, splitNode } from './edges.js';
|
|
9
|
+
import { vocabularyOf } from './query.js';
|
|
10
|
+
const movesFrom = (graph, node, observed) => {
|
|
11
|
+
const moves = observed.map((e) => ({
|
|
12
|
+
to: edgeTo(e),
|
|
13
|
+
press: e.via.startsWith('text:') ? undefined : e.via,
|
|
14
|
+
hint: e.via.startsWith('text:') ? e.via : undefined,
|
|
15
|
+
opens: e.toLayer !== e.fromLayer ? e.toLayer : undefined,
|
|
16
|
+
source: 'observed',
|
|
17
|
+
}));
|
|
18
|
+
// Source knows routes, not what a route has open: a static edge only applies where nothing covers the screen.
|
|
19
|
+
const { route, layer } = splitNode(node);
|
|
20
|
+
if (layer !== undefined)
|
|
21
|
+
return moves;
|
|
22
|
+
const vocabulary = vocabularyOf(graph, route, 1);
|
|
23
|
+
for (const edge of vocabulary?.navigatesTo ?? []) {
|
|
24
|
+
if (edge.confidence !== 'static' || !graph.screens[edge.to])
|
|
25
|
+
continue;
|
|
26
|
+
if (moves.some((m) => m.to === edge.to))
|
|
27
|
+
continue;
|
|
28
|
+
moves.push({
|
|
29
|
+
to: edge.to,
|
|
30
|
+
press: edge.via.kind === 'testId' ? edge.via.testId : undefined,
|
|
31
|
+
hint: edge.via.kind === 'dynamicTestId'
|
|
32
|
+
? `testId built at runtime: ${edge.via.expression}`
|
|
33
|
+
: edge.via.kind === 'unknown'
|
|
34
|
+
? `navigated from ${edge.from}:${edge.line} without a visible trigger`
|
|
35
|
+
: undefined,
|
|
36
|
+
source: 'static',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return moves;
|
|
40
|
+
};
|
|
41
|
+
/** Breadth-first over places. A bare route as the destination accepts arriving with something open over it. */
|
|
42
|
+
export const plan = (graph, from, to, observed = [], maxDepth = 6) => {
|
|
43
|
+
const byNode = new Map();
|
|
44
|
+
for (const e of observed) {
|
|
45
|
+
const list = byNode.get(edgeFrom(e));
|
|
46
|
+
if (list)
|
|
47
|
+
list.push(e);
|
|
48
|
+
else
|
|
49
|
+
byNode.set(edgeFrom(e), [e]);
|
|
50
|
+
}
|
|
51
|
+
const arrived = (node) => node === to || splitNode(node).route === to;
|
|
52
|
+
if (arrived(from))
|
|
53
|
+
return [];
|
|
54
|
+
const previous = new Map();
|
|
55
|
+
const queue = [from];
|
|
56
|
+
const depth = new Map([[from, 0]]);
|
|
57
|
+
while (queue.length) {
|
|
58
|
+
const node = queue.shift();
|
|
59
|
+
const d = depth.get(node) ?? 0;
|
|
60
|
+
if (d >= maxDepth)
|
|
61
|
+
continue;
|
|
62
|
+
for (const move of movesFrom(graph, node, byNode.get(node) ?? [])) {
|
|
63
|
+
if (previous.has(move.to) || move.to === from)
|
|
64
|
+
continue;
|
|
65
|
+
previous.set(move.to, { from: node, to: move.to, press: move.press, hint: move.hint, opens: move.opens, source: move.source });
|
|
66
|
+
depth.set(move.to, d + 1);
|
|
67
|
+
if (arrived(move.to)) {
|
|
68
|
+
const steps = [];
|
|
69
|
+
for (let at = move.to; at && at !== from; at = previous.get(at)?.from) {
|
|
70
|
+
steps.unshift(previous.get(at));
|
|
71
|
+
}
|
|
72
|
+
return steps;
|
|
73
|
+
}
|
|
74
|
+
queue.push(move.to);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
};
|
|
79
|
+
export const describePlan = (steps) => steps
|
|
80
|
+
.map((s, i) => `${i + 1}. ${s.from} → ${s.to} ${s.press ? `press #${s.press}` : `(${s.hint ?? 'look at the screen'})`}${s.opens ? ' (opens a layer)' : ''} [${s.source}]`)
|
|
81
|
+
.join('\n');
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RequestRecord, Screen } from 'lynkeus-protocol';
|
|
2
|
+
export declare const withoutHost: (url: string) => string;
|
|
3
|
+
export declare const pathOf: (url: string) => string;
|
|
4
|
+
/** What a person would call broken on a screen: nothing on it, the app's own error copy, a request that failed. */
|
|
5
|
+
export declare const problemsOf: (screen: Screen, requests: RequestRecord[], errorCopy?: RegExp) => string[];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const withoutHost = (url) => url.replace(/^https?:\/\/[^/]+/, '');
|
|
2
|
+
export const pathOf = (url) => withoutHost(url).split('?')[0];
|
|
3
|
+
/** What a person would call broken on a screen: nothing on it, the app's own error copy, a request that failed. */
|
|
4
|
+
export const problemsOf = (screen, requests, errorCopy) => [
|
|
5
|
+
...(screen.elements.length === 0 ? ['blank screen'] : []),
|
|
6
|
+
...screen.elements.flatMap((e) => (errorCopy && e.kind === 'text' && e.text && errorCopy.test(e.text) ? [`copy: ${e.text.slice(0, 80)}`] : [])),
|
|
7
|
+
...requests.filter((r) => (r.status ?? 0) >= 500 || r.error).map((r) => `${r.status ?? 'ERR'} ${r.method} ${withoutHost(r.url)}`),
|
|
8
|
+
];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ObservedEdge } from './edges.js';
|
|
2
|
+
import type { NavigationEdge, RouteName, ScreenGraph, Selector, SourcePath } from './types.js';
|
|
3
|
+
export type Vocabulary = {
|
|
4
|
+
route: RouteName;
|
|
5
|
+
file: SourcePath | null;
|
|
6
|
+
selectors: (Selector & {
|
|
7
|
+
from: SourcePath;
|
|
8
|
+
})[];
|
|
9
|
+
navigatesTo: (NavigationEdge & {
|
|
10
|
+
from: SourcePath;
|
|
11
|
+
})[];
|
|
12
|
+
filesConsidered: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A weak guess at what a screen offers, from its file and what it imports
|
|
16
|
+
* directly. One hop only: through barrels the whole component library is two
|
|
17
|
+
* hops from every screen. The running app is the ground truth.
|
|
18
|
+
*/
|
|
19
|
+
export declare const vocabularyOf: (graph: ScreenGraph, route: RouteName, maxDepth?: number) => Vocabulary | null;
|
|
20
|
+
export type Approach = {
|
|
21
|
+
via: string;
|
|
22
|
+
source: 'static' | 'observed';
|
|
23
|
+
/** The file that declares it, or the place it was walked from. */
|
|
24
|
+
from: string;
|
|
25
|
+
seen?: number;
|
|
26
|
+
};
|
|
27
|
+
/** The controls that lead to a route, the walked ones first: they beat an inference from source. */
|
|
28
|
+
export declare const pathsTo: (graph: ScreenGraph, destination: RouteName, observed?: ObservedEdge[]) => Approach[];
|
|
29
|
+
export type Affected = {
|
|
30
|
+
route: RouteName;
|
|
31
|
+
distance: number;
|
|
32
|
+
via: SourcePath;
|
|
33
|
+
through: SourcePath[];
|
|
34
|
+
};
|
|
35
|
+
/** Screens a change can reach, nearest first; `distance` is how many imports separate the screen's file from a changed one. */
|
|
36
|
+
export declare const screensAffectedBy: (graph: ScreenGraph, changedFiles: SourcePath[], maxDepth?: number) => Affected[];
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { edgeFrom } from './edges.js';
|
|
2
|
+
// Bounded: through barrels an unbounded closure reaches most of the app and makes every screen look the same.
|
|
3
|
+
const closureOf = (graph, entry, maxDepth = 2) => {
|
|
4
|
+
const seen = new Set([entry]);
|
|
5
|
+
let frontier = [entry];
|
|
6
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
7
|
+
const next = [];
|
|
8
|
+
for (const file of frontier) {
|
|
9
|
+
for (const imported of graph.files[file]?.imports ?? []) {
|
|
10
|
+
if (seen.has(imported))
|
|
11
|
+
continue;
|
|
12
|
+
seen.add(imported);
|
|
13
|
+
next.push(imported);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (next.length === 0)
|
|
17
|
+
break;
|
|
18
|
+
frontier = next;
|
|
19
|
+
}
|
|
20
|
+
return seen;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* A weak guess at what a screen offers, from its file and what it imports
|
|
24
|
+
* directly. One hop only: through barrels the whole component library is two
|
|
25
|
+
* hops from every screen. The running app is the ground truth.
|
|
26
|
+
*/
|
|
27
|
+
export const vocabularyOf = (graph, route, maxDepth = 1) => {
|
|
28
|
+
const screen = graph.screens[route];
|
|
29
|
+
if (!screen)
|
|
30
|
+
return null;
|
|
31
|
+
if (!screen.file) {
|
|
32
|
+
return { route, file: null, selectors: [], navigatesTo: [], filesConsidered: 0 };
|
|
33
|
+
}
|
|
34
|
+
const closure = closureOf(graph, screen.file, maxDepth);
|
|
35
|
+
const selectors = [];
|
|
36
|
+
const navigatesTo = [];
|
|
37
|
+
for (const file of closure) {
|
|
38
|
+
const facts = graph.files[file];
|
|
39
|
+
if (!facts)
|
|
40
|
+
continue;
|
|
41
|
+
for (const selector of facts.selectors)
|
|
42
|
+
selectors.push({ ...selector, from: file });
|
|
43
|
+
for (const edge of facts.navigates)
|
|
44
|
+
navigatesTo.push({ ...edge, from: file });
|
|
45
|
+
}
|
|
46
|
+
return { route, file: screen.file, selectors, navigatesTo, filesConsidered: closure.size };
|
|
47
|
+
};
|
|
48
|
+
/** The controls that lead to a route, the walked ones first: they beat an inference from source. */
|
|
49
|
+
export const pathsTo = (graph, destination, observed = []) => {
|
|
50
|
+
const approaches = observed
|
|
51
|
+
.filter((edge) => edge.to === destination)
|
|
52
|
+
.map((edge) => ({
|
|
53
|
+
via: edge.via,
|
|
54
|
+
source: 'observed',
|
|
55
|
+
from: edgeFrom(edge),
|
|
56
|
+
seen: edge.seen,
|
|
57
|
+
}));
|
|
58
|
+
for (const [file, facts] of Object.entries(graph.files)) {
|
|
59
|
+
for (const edge of facts.navigates) {
|
|
60
|
+
if (edge.to !== destination || edge.confidence !== 'static')
|
|
61
|
+
continue;
|
|
62
|
+
if (edge.via.kind === 'unknown')
|
|
63
|
+
continue;
|
|
64
|
+
const via = edge.via.kind === 'testId' ? edge.via.testId : `<runtime: ${edge.via.expression}>`;
|
|
65
|
+
if (approaches.some((a) => a.via === via))
|
|
66
|
+
continue;
|
|
67
|
+
approaches.push({ via, source: 'static', from: file });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return approaches;
|
|
71
|
+
};
|
|
72
|
+
/** Screens a change can reach, nearest first; `distance` is how many imports separate the screen's file from a changed one. */
|
|
73
|
+
export const screensAffectedBy = (graph, changedFiles, maxDepth = 4) => {
|
|
74
|
+
const importedBy = new Map();
|
|
75
|
+
for (const [file, facts] of Object.entries(graph.files)) {
|
|
76
|
+
for (const target of facts.imports) {
|
|
77
|
+
const list = importedBy.get(target);
|
|
78
|
+
if (list)
|
|
79
|
+
list.push(file);
|
|
80
|
+
else
|
|
81
|
+
importedBy.set(target, [file]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const throughBarrel = new Map();
|
|
85
|
+
for (const [file, facts] of Object.entries(graph.files)) {
|
|
86
|
+
for (const barrel of facts.barrels ?? []) {
|
|
87
|
+
const list = throughBarrel.get(barrel);
|
|
88
|
+
if (list)
|
|
89
|
+
list.push(file);
|
|
90
|
+
else
|
|
91
|
+
throughBarrel.set(barrel, [file]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const reached = new Map();
|
|
95
|
+
let frontier = [];
|
|
96
|
+
for (const f of changedFiles) {
|
|
97
|
+
if (!reached.has(f)) {
|
|
98
|
+
reached.set(f, { distance: 0, via: f, through: [] });
|
|
99
|
+
frontier.push(f);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
|
103
|
+
const next = [];
|
|
104
|
+
for (const file of frontier) {
|
|
105
|
+
const hop = reached.get(file);
|
|
106
|
+
// Taking a name through a barrel depends on the barrel only when the barrel itself changed.
|
|
107
|
+
const dependents = hop.distance === 0 ? [...(importedBy.get(file) ?? []), ...(throughBarrel.get(file) ?? [])] : (importedBy.get(file) ?? []);
|
|
108
|
+
for (const dependent of dependents) {
|
|
109
|
+
if (reached.has(dependent))
|
|
110
|
+
continue;
|
|
111
|
+
reached.set(dependent, { distance: depth, via: hop.via, through: [...hop.through, file] });
|
|
112
|
+
next.push(dependent);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
frontier = next;
|
|
116
|
+
}
|
|
117
|
+
const affected = [];
|
|
118
|
+
for (const screen of Object.values(graph.screens)) {
|
|
119
|
+
if (!screen.file)
|
|
120
|
+
continue;
|
|
121
|
+
const hop = reached.get(screen.file);
|
|
122
|
+
if (!hop)
|
|
123
|
+
continue;
|
|
124
|
+
affected.push({ route: screen.route, distance: hop.distance, via: hop.via, through: hop.through.slice(1) });
|
|
125
|
+
}
|
|
126
|
+
return affected.sort((a, b) => a.distance - b.distance || a.route.localeCompare(b.route));
|
|
127
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
export const readRouteParams = (root, paramsFile = 'app/navigation/types.d.ts') => {
|
|
8
|
+
const file = path.join(root, paramsFile);
|
|
9
|
+
if (!fs.existsSync(file))
|
|
10
|
+
return [];
|
|
11
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
12
|
+
const start = Math.max(0, text.search(/\b\w*ParamList\b|ScreenRouteParams/));
|
|
13
|
+
const out = new Map();
|
|
14
|
+
const entry = /'([A-Z][\w.]+)':\s*/g;
|
|
15
|
+
entry.lastIndex = start;
|
|
16
|
+
for (const match of text.matchAll(entry)) {
|
|
17
|
+
const route = match[1];
|
|
18
|
+
let i = match.index + match[0].length;
|
|
19
|
+
let shape = '';
|
|
20
|
+
if (text.startsWith('undefined', i)) {
|
|
21
|
+
shape = 'undefined';
|
|
22
|
+
}
|
|
23
|
+
else if (text[i] === '{') {
|
|
24
|
+
let depth = 0;
|
|
25
|
+
const from = i;
|
|
26
|
+
for (; i < text.length; i++) {
|
|
27
|
+
if (text[i] === '{')
|
|
28
|
+
depth++;
|
|
29
|
+
else if (text[i] === '}' && --depth === 0)
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
shape = text.slice(from, i + 1);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
shape = text.slice(i, text.indexOf('\n', i)).trim();
|
|
36
|
+
}
|
|
37
|
+
const free = shape === 'undefined' || (shape.startsWith('{') && !/^\s*\w+\s*:/m.test(shape.slice(1, -1).replace(/\{[^}]*\}/g, '')));
|
|
38
|
+
if (!out.has(route))
|
|
39
|
+
out.set(route, { route, free, shape: shape.replace(/\s+/g, ' ').slice(0, 120) });
|
|
40
|
+
}
|
|
41
|
+
return [...out.values()];
|
|
42
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Screen } from 'lynkeus-protocol';
|
|
2
|
+
type Frame = {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
w: number;
|
|
6
|
+
h: number;
|
|
7
|
+
};
|
|
8
|
+
export type ScreenChange = {
|
|
9
|
+
route: string;
|
|
10
|
+
date: string;
|
|
11
|
+
added: string[];
|
|
12
|
+
removed: string[];
|
|
13
|
+
changedText: string[];
|
|
14
|
+
moved?: string[];
|
|
15
|
+
};
|
|
16
|
+
export declare const diffGeometry: (before: Record<string, Frame>, after: Record<string, Frame>, tolerance?: number) => string[];
|
|
17
|
+
/** Stores the screen under its route and returns what changed since the previous read. */
|
|
18
|
+
export declare const record: (lynkeusDir: string, screen: Screen) => ScreenChange | null;
|
|
19
|
+
export type Observed = {
|
|
20
|
+
route: string;
|
|
21
|
+
path: string[];
|
|
22
|
+
seen: number;
|
|
23
|
+
date: string;
|
|
24
|
+
testIds: string[];
|
|
25
|
+
};
|
|
26
|
+
/** What runs have seen, by route: the routes and interpolated testIDs a static parser cannot find. */
|
|
27
|
+
export declare const observed: (lynkeusDir: string) => Map<string, Observed>;
|
|
28
|
+
export declare const changes: (lynkeusDir: string, last?: number) => ScreenChange[];
|
|
29
|
+
export declare const describeChange: (c: ScreenChange) => string;
|
|
30
|
+
export {};
|