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,130 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import type { Screen } from 'lynkeus-protocol';
|
|
4
|
+
|
|
5
|
+
import type { Device } from '../../device/driver.js';
|
|
6
|
+
import { runFlow } from '../../flow/runner.js';
|
|
7
|
+
import type { CrawlOptions } from '../crawl.js';
|
|
8
|
+
import { clearOverlayIfShown } from '../overlay.js';
|
|
9
|
+
import { isCloser, presented, stateKey, targetFor, targetOf } from './state.js';
|
|
10
|
+
|
|
11
|
+
/** Fatal: exploring a logged-out app would record its intro screens as reachable from logged-in ones, and poison `plan`. */
|
|
12
|
+
export class RecoveryError extends Error {}
|
|
13
|
+
|
|
14
|
+
type Context = { device: Device; options: CrawlOptions; goTo: (route: string | undefined) => Promise<void> };
|
|
15
|
+
|
|
16
|
+
/** How the crawl gets back to where it was standing, or back to life. */
|
|
17
|
+
export const createReturns = ({ device, options, goTo }: Context) => {
|
|
18
|
+
const dead = (s: Screen) => !s.route && s.elements.length === 0;
|
|
19
|
+
const pressed = (target: ReturnType<typeof targetFor>, timeoutMs: number) =>
|
|
20
|
+
device
|
|
21
|
+
.waitFor({ target, timeoutMs })
|
|
22
|
+
.then(() => device.press(target))
|
|
23
|
+
.then(
|
|
24
|
+
() => true,
|
|
25
|
+
() => false,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const logIn = async (login: NonNullable<CrawlOptions['login']>) => {
|
|
29
|
+
const entry = (await device.screen()).route;
|
|
30
|
+
const vars = { ...login.vars };
|
|
31
|
+
for (const flow of login.flows) {
|
|
32
|
+
for (const name of flow.vars ?? []) {
|
|
33
|
+
const command = login.varCommands?.[name];
|
|
34
|
+
if (!command) continue;
|
|
35
|
+
options.log?.(`${name} ← ${command}`);
|
|
36
|
+
vars[name] = execSync(command, { encoding: 'utf8' }).trim();
|
|
37
|
+
if (!vars[name]) throw new RecoveryError(`the command for {{${name}}} printed nothing`);
|
|
38
|
+
}
|
|
39
|
+
options.log?.(`login: ${flow.name}`);
|
|
40
|
+
const report = await runFlow(flow, { device, vars });
|
|
41
|
+
if (!report.ok) throw new RecoveryError(`login flow "${flow.name}" failed at step ${report.steps.length}`);
|
|
42
|
+
}
|
|
43
|
+
const after = await device.screen();
|
|
44
|
+
if (!after.route || after.route === entry) throw new RecoveryError(`login left the app on ${after.route ?? 'no route'}`);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const recover = async () => {
|
|
48
|
+
if (device.server.hello?.app?.runtime === 'headless') {
|
|
49
|
+
// A host has no process to relaunch; a reset mounts the app again from scratch.
|
|
50
|
+
await device.command('reset');
|
|
51
|
+
if (options.login) await logIn(options.login);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!options.appId) throw new RecoveryError('App disconnected and no --app to relaunch');
|
|
55
|
+
await device.launchApp(options.appId, { fresh: true });
|
|
56
|
+
await clearOverlayIfShown(device, options.overlay);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Sheets animate on the UI thread after React has gone quiet: a screen whose controls are all still covered gets one more look.
|
|
60
|
+
const settle = async (): Promise<Screen> => {
|
|
61
|
+
await device.idle({ quietMs: 150, timeoutMs: 800 });
|
|
62
|
+
const screen = await device.screen();
|
|
63
|
+
const controls = presented(screen).filter((e) => e.kind === 'button');
|
|
64
|
+
if (controls.length === 0 || !controls.every((e) => e.covered)) return screen;
|
|
65
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
66
|
+
return device.screen();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const changedFrom = async (now: Screen) => {
|
|
70
|
+
await device.idle({ quietMs: 150, timeoutMs: 800 });
|
|
71
|
+
return stateKey(await device.screen()) !== stateKey(now);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Without knowing what an app calls its close button: its declared closer, then its backdrop, a swipe down, back. */
|
|
75
|
+
const closeOverlay = async (now: Screen) => {
|
|
76
|
+
// Only what the layer offers: a "Close" on the screen it covers is out of reach.
|
|
77
|
+
const closer = options.closers ? presented(now).find((e) => isCloser(e, options.closers!)) : undefined;
|
|
78
|
+
if (closer) return device.press(targetOf(closer));
|
|
79
|
+
const backdrop = now.presenting ? now.elements[now.presenting.i] : undefined;
|
|
80
|
+
if (backdrop?.kind === 'button' && backdrop.enabled) {
|
|
81
|
+
await device.press(targetOf(backdrop));
|
|
82
|
+
if (await changedFrom(now)) return;
|
|
83
|
+
}
|
|
84
|
+
await device.swipe({ direction: 'down' });
|
|
85
|
+
if (await changedFrom(now)) return;
|
|
86
|
+
await device.back().catch(() => undefined);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const revive = async (): Promise<Screen> => {
|
|
90
|
+
const now = await settle();
|
|
91
|
+
if (!dead(now)) return now;
|
|
92
|
+
options.log?.('navigator is dead; recovering before exploring');
|
|
93
|
+
await recover().catch(() => undefined);
|
|
94
|
+
return settle();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Back on the route is enough: lists and banners drift between visits. */
|
|
98
|
+
const returnTo = async (origin: Screen): Promise<boolean> => {
|
|
99
|
+
for (let i = 0; i < 4; i++) {
|
|
100
|
+
let now = await device.screen();
|
|
101
|
+
if (dead(now)) {
|
|
102
|
+
options.log?.('navigator is dead; recovering');
|
|
103
|
+
await recover();
|
|
104
|
+
now = await device.screen();
|
|
105
|
+
}
|
|
106
|
+
if (now.route === origin.route && !now.presenting) return true;
|
|
107
|
+
if (now.route !== origin.route) {
|
|
108
|
+
const popped = await device.back().then(
|
|
109
|
+
() => true,
|
|
110
|
+
() => false,
|
|
111
|
+
);
|
|
112
|
+
if (!popped) await goTo(origin.route);
|
|
113
|
+
} else await closeOverlay(now);
|
|
114
|
+
await device.idle({ quietMs: 200, timeoutMs: 1500 });
|
|
115
|
+
}
|
|
116
|
+
const back = (await device.screen()).route === origin.route;
|
|
117
|
+
if (!back) options.log?.(`could not return to ${origin.route ?? '?'}`);
|
|
118
|
+
return back;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** A sheet is named after the control that opens it, so getting back in is pressing that control again. */
|
|
122
|
+
const restore = async (origin: Screen, layer?: string): Promise<boolean> => {
|
|
123
|
+
if (!(await returnTo(origin))) return false;
|
|
124
|
+
if (!layer || (await device.screen()).presenting) return true;
|
|
125
|
+
if (!(await pressed(targetFor(layer), 800))) return false;
|
|
126
|
+
return (await settle()).presenting !== undefined;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return { dead, recover, settle, revive, restore };
|
|
130
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import type { Element, Screen, Target } from 'lynkeus-protocol';
|
|
4
|
+
|
|
5
|
+
const layerOf = (s: Screen) => s.presenting?.testId ?? (s.presenting?.modal ? 'modal' : 'overlay');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What makes a screen a different screen: its route, the layer over it and the
|
|
9
|
+
* set of controls it offers. A set because carousels and lists repeat testIDs
|
|
10
|
+
* and animate; hashed rather than truncated, because cutting a long list made
|
|
11
|
+
* two screens look identical and the crawl never went inside the second.
|
|
12
|
+
*/
|
|
13
|
+
export const stateKey = (s: Screen) => {
|
|
14
|
+
const ids = [
|
|
15
|
+
...new Set(
|
|
16
|
+
presented(s)
|
|
17
|
+
.filter((e) => e.kind === 'button' && e.testId && !e.covered)
|
|
18
|
+
.map((e) => e.testId),
|
|
19
|
+
),
|
|
20
|
+
]
|
|
21
|
+
.sort()
|
|
22
|
+
.join(',');
|
|
23
|
+
return `${s.route ?? '?'}${s.presenting ? `+${layerOf(s)}` : ''}|${createHash('sha1').update(ids).digest('hex').slice(0, 12)}`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Another tab, or the screen a sheet covers, is mounted but out of a finger's reach. */
|
|
27
|
+
export const presented = (s: Screen): Element[] => s.elements.filter((e) => !e.hidden);
|
|
28
|
+
|
|
29
|
+
/** A sheet has no route, so it is named after the control that opened it: that is also how to get back into it. */
|
|
30
|
+
export const layerName = (s: Screen, openedBy?: string): string | undefined => (s.presenting ? (openedBy ?? layerOf(s)) : undefined);
|
|
31
|
+
|
|
32
|
+
export const controlName = (e: Element): string => e.testId ?? `text:${e.text ?? ''}`;
|
|
33
|
+
|
|
34
|
+
/** Where the agent stands; a sheet that mounted a beat after the press still belongs to itself, not to the screen under it. */
|
|
35
|
+
export const nodeOf = (s: Screen, layer?: string): string => {
|
|
36
|
+
const here = layer ?? (s.presenting ? (s.presenting.testId ?? 'overlay') : undefined);
|
|
37
|
+
return `${s.route ?? '?'}${here ? `#${here}` : ''}`;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** A control by the name the frontier and the edges record it under. */
|
|
41
|
+
export const targetFor = (control: string): Target => (control.startsWith('text:') ? { text: control.slice(5) } : { testId: control });
|
|
42
|
+
|
|
43
|
+
export const targetOf = (e: Element): Target => targetFor(controlName(e));
|
|
44
|
+
|
|
45
|
+
// List rows carry their record id in the testID; one row per pattern is enough to learn where rows lead.
|
|
46
|
+
export const pattern = (e: Element) => (e.testId ?? e.text ?? '').replace(/[0-9a-f]{8}-[0-9a-f-]{27}|\d+/gi, '*');
|
|
47
|
+
|
|
48
|
+
const words = (e: Element) => `${e.testId ?? ''} ${e.text ?? ''}`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The controls worth pressing. A closer or a back control leads to where the
|
|
52
|
+
* crawl came from, not somewhere new; without an app's own list of back
|
|
53
|
+
* controls, a small one in the top-left corner is taken for one.
|
|
54
|
+
*/
|
|
55
|
+
export const candidates = (s: Screen, neverPress?: RegExp, backs?: RegExp, closers?: RegExp): Element[] => {
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
const corner = (e: Element) => e.frame.x < s.window.w / 4 && e.frame.y < s.window.h / 8 && e.frame.w < s.window.w / 4;
|
|
58
|
+
return presented(s).filter((e) => {
|
|
59
|
+
if (e.kind !== 'button' || !e.enabled || e.covered || !(e.testId || e.text)) return false;
|
|
60
|
+
if (neverPress?.test(words(e)) || closers?.test(words(e)) || (backs ? backs.test(words(e)) : corner(e))) return false;
|
|
61
|
+
const key = pattern(e);
|
|
62
|
+
if (seen.has(key)) return false;
|
|
63
|
+
seen.add(key);
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const label = (e: Element) => ({ testId: e.testId, text: e.text?.slice(0, 40) });
|
|
69
|
+
|
|
70
|
+
export const isCloser = (e: Element, closers: RegExp) => e.kind === 'button' && e.enabled && !e.covered && closers.test(words(e));
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import type { Element, Screen } from 'lynkeus-protocol';
|
|
5
|
+
|
|
6
|
+
import type { Device } from '../device/driver.js';
|
|
7
|
+
import type { Flow } from '../flow/types.js';
|
|
8
|
+
import { pathOf, problemsOf } from '../knowledge/problems.js';
|
|
9
|
+
import { createReturns, RecoveryError } from './crawl/return.js';
|
|
10
|
+
import { candidates, controlName, label, layerName, nodeOf, pattern, presented, stateKey, targetOf } from './crawl/state.js';
|
|
11
|
+
import { clearOverlayIfShown, type Overlay } from './overlay.js';
|
|
12
|
+
|
|
13
|
+
export type LoginRecovery = {
|
|
14
|
+
/** Run in order after a reset, with `vars`; the last should land logged in. */
|
|
15
|
+
flows: Flow[];
|
|
16
|
+
vars: Record<string, unknown>;
|
|
17
|
+
/** Shell commands that print a variable, run right before a flow that declares it: a one-time code, a minted session. */
|
|
18
|
+
varCommands?: Record<string, string>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// The one signal about a press's consequences that holds in any app, with no vocabulary.
|
|
22
|
+
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
23
|
+
|
|
24
|
+
export type Transition = {
|
|
25
|
+
from: string;
|
|
26
|
+
fromRoute?: string;
|
|
27
|
+
fromLayer?: string;
|
|
28
|
+
toLayer?: string;
|
|
29
|
+
button: { testId?: string; text?: string };
|
|
30
|
+
to: string;
|
|
31
|
+
toRoute?: string;
|
|
32
|
+
ms: number;
|
|
33
|
+
problems: string[];
|
|
34
|
+
/** The writes the press caused, when it caused any. */
|
|
35
|
+
wrote?: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type CrawlOptions = {
|
|
39
|
+
device: Device;
|
|
40
|
+
outDir: string;
|
|
41
|
+
depth: number;
|
|
42
|
+
maxActions: number;
|
|
43
|
+
neverPress?: RegExp;
|
|
44
|
+
errorCopy?: RegExp;
|
|
45
|
+
/** Without one, an overlay is closed by tapping its backdrop, then by a swipe down. */
|
|
46
|
+
closers?: RegExp;
|
|
47
|
+
backs?: RegExp;
|
|
48
|
+
/** `read-only` (default): a press that makes the app write is reported, never repeated, and not explored past. */
|
|
49
|
+
safety?: 'read-only' | 'anything';
|
|
50
|
+
appId?: string;
|
|
51
|
+
overlay?: Overlay;
|
|
52
|
+
/** How to log in again after a headless host is reset (a crashed screen kills its navigator). */
|
|
53
|
+
login?: LoginRecovery;
|
|
54
|
+
/** Press what the directions say leads here first, and stop on arrival. */
|
|
55
|
+
goal?: string;
|
|
56
|
+
/** What earlier crawls pressed, and what this one learns. */
|
|
57
|
+
memory?: {
|
|
58
|
+
tried: (node: string) => string[];
|
|
59
|
+
record: (node: string, update: { tried?: string[]; seen?: string[]; visited?: boolean }) => void;
|
|
60
|
+
/** Places with controls nobody has pressed, most owed first. */
|
|
61
|
+
unfinished: () => string[];
|
|
62
|
+
};
|
|
63
|
+
directions?: (from: string, to: string) => { press?: string; to: string }[] | null;
|
|
64
|
+
onTransition?: (t: Transition) => void;
|
|
65
|
+
log?: (line: string) => void;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const crawl = async (options: CrawlOptions): Promise<{ transitions: Transition[]; states: Record<string, Screen> }> => {
|
|
69
|
+
const { device, memory } = options;
|
|
70
|
+
const readOnly = (options.safety ?? 'read-only') === 'read-only';
|
|
71
|
+
fs.mkdirSync(options.outDir, { recursive: true });
|
|
72
|
+
const transitions: Transition[] = [];
|
|
73
|
+
const states: Record<string, Screen> = {};
|
|
74
|
+
const explored = new Set<string>();
|
|
75
|
+
const wrote = new Set<string>();
|
|
76
|
+
const walked = new Set<string>();
|
|
77
|
+
let actions = 0;
|
|
78
|
+
let reached = false;
|
|
79
|
+
let start: string | undefined;
|
|
80
|
+
const goTo = async (route: string | undefined): Promise<void> => {
|
|
81
|
+
const target = route ?? start;
|
|
82
|
+
if (target) await device.navigate(target);
|
|
83
|
+
else await device.back();
|
|
84
|
+
};
|
|
85
|
+
const { dead, recover, settle, revive, restore } = createReturns({ device, options, goTo });
|
|
86
|
+
|
|
87
|
+
const report = (transition: Transition, t0: number) => {
|
|
88
|
+
transition.ms = Math.round(performance.now() - t0);
|
|
89
|
+
transitions.push(transition);
|
|
90
|
+
options.onTransition?.(transition);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** With a goal, whatever the directions say leads towards it goes first. */
|
|
94
|
+
const ordered = (buttons: Element[], next: string | undefined): Element[] => {
|
|
95
|
+
const toward = next ? buttons.filter((b) => b.testId === next) : [];
|
|
96
|
+
if (toward.length === 0) return buttons;
|
|
97
|
+
options.log?.(`towards ${options.goal}: press ${next} first`);
|
|
98
|
+
return [...toward, ...buttons.filter((b) => !toward.includes(b))];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const explore = async (origin: Screen, depth: number, layer?: string): Promise<void> => {
|
|
102
|
+
const key = stateKey(origin);
|
|
103
|
+
if (explored.has(key) || depth > options.depth || reached) return;
|
|
104
|
+
explored.add(key);
|
|
105
|
+
states[key] = origin;
|
|
106
|
+
const node = nodeOf(origin, layer);
|
|
107
|
+
const all = candidates(origin, options.neverPress, options.backs, options.closers);
|
|
108
|
+
// Only a testID is promised to a later run: a control known by its text may render differently next time.
|
|
109
|
+
memory?.record(node, { seen: all.filter((b) => b.testId).map(controlName), visited: true });
|
|
110
|
+
const done = new Set(memory?.tried(node) ?? []);
|
|
111
|
+
const next = options.goal && options.directions ? options.directions(node, options.goal)?.[0]?.press : undefined;
|
|
112
|
+
const buttons = ordered(
|
|
113
|
+
all.filter((b) => !done.has(controlName(b))),
|
|
114
|
+
next,
|
|
115
|
+
);
|
|
116
|
+
const found = new Map<string, { button: Element; layer?: string }>();
|
|
117
|
+
options.log?.(
|
|
118
|
+
`explore ${origin.route ?? '?'}${layer ? ` +${layer}` : ''} depth ${depth}: ${buttons.length} buttons ` +
|
|
119
|
+
`(${origin.elements.filter((e) => e.hidden).length} hidden, ${presented(origin).filter((e) => e.covered).length} covered)`,
|
|
120
|
+
);
|
|
121
|
+
for (const button of buttons) {
|
|
122
|
+
if (actions >= options.maxActions || reached) return;
|
|
123
|
+
if (readOnly && wrote.has(pattern(button))) continue;
|
|
124
|
+
const t0 = performance.now();
|
|
125
|
+
const transition: Transition = { from: key, fromRoute: origin.route, fromLayer: layer, button: label(button), to: '', ms: 0, problems: [] };
|
|
126
|
+
try {
|
|
127
|
+
if (!device.server.connected) {
|
|
128
|
+
await recover();
|
|
129
|
+
if (!(await restore(origin, layer))) return;
|
|
130
|
+
}
|
|
131
|
+
const before = await device.requests();
|
|
132
|
+
const present = await device.waitFor({ target: targetOf(button), timeoutMs: 800 }).then(
|
|
133
|
+
() => true,
|
|
134
|
+
() => false,
|
|
135
|
+
);
|
|
136
|
+
if (!present) {
|
|
137
|
+
options.log?.(`skip ${controlName(button)}: no longer on screen`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
actions++;
|
|
141
|
+
await device.press(targetOf(button));
|
|
142
|
+
const after = await settle();
|
|
143
|
+
transition.to = stateKey(after);
|
|
144
|
+
transition.toRoute = after.route;
|
|
145
|
+
// A press made inside a layer that is still up stays in it; one that raised a layer names it.
|
|
146
|
+
transition.toLayer = layerName(after, origin.presenting ? layer : controlName(button));
|
|
147
|
+
const { requests } = await device.requests(before.requests.at(-1)?.id);
|
|
148
|
+
transition.problems.push(...problemsOf(after, requests, options.errorCopy));
|
|
149
|
+
const writes = requests.filter((r) => WRITE_METHODS.has(r.method.toUpperCase()));
|
|
150
|
+
if (writes.length) {
|
|
151
|
+
transition.wrote = [...new Set(writes.map((r) => `${r.method} ${pathOf(r.url)}`))];
|
|
152
|
+
wrote.add(pattern(button));
|
|
153
|
+
}
|
|
154
|
+
report(transition, t0);
|
|
155
|
+
memory?.record(node, { tried: [controlName(button)] });
|
|
156
|
+
if (options.goal && after.route === options.goal) {
|
|
157
|
+
options.log?.(`reached ${options.goal}`);
|
|
158
|
+
reached = true;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (transition.to === key) continue;
|
|
162
|
+
if (transition.wrote && readOnly) {
|
|
163
|
+
options.log?.(`stop here: pressing ${controlName(button)} wrote (${transition.wrote.join(', ')})`);
|
|
164
|
+
} else if (next !== undefined && button.testId === next) {
|
|
165
|
+
// Getting somewhere in particular: follow the directions now instead of sweeping this screen first.
|
|
166
|
+
await explore(after, depth + 1, transition.toLayer);
|
|
167
|
+
} else if (!explored.has(transition.to) && !found.has(transition.to)) {
|
|
168
|
+
found.set(transition.to, { button, layer: transition.toLayer });
|
|
169
|
+
}
|
|
170
|
+
if (after.route) memory?.record(nodeOf(after, transition.toLayer), {});
|
|
171
|
+
if (!device.server.connected) await recover();
|
|
172
|
+
if (!(await restore(origin, layer))) {
|
|
173
|
+
await goTo(origin.route);
|
|
174
|
+
await device.idle({ quietMs: 200, timeoutMs: 1500 });
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error instanceof RecoveryError) throw error;
|
|
178
|
+
transition.problems.push(error instanceof Error ? error.message : String(error));
|
|
179
|
+
report(transition, t0);
|
|
180
|
+
if (!device.server.connected) await recover().catch(() => undefined);
|
|
181
|
+
if (!(await restore(origin, layer).catch(() => false))) await goTo(origin.route).catch(() => undefined);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The whole screen is swept before going into what it opened: depth-first from the first control would
|
|
186
|
+
// spend the budget inside it. Layers go first: one only exists while this screen holds it open, while a
|
|
187
|
+
// route stays reachable by name.
|
|
188
|
+
const queue = [...found].sort(([, a], [, b]) => Number(b.layer !== undefined) - Number(a.layer !== undefined));
|
|
189
|
+
for (const [to, { button, layer: into }] of queue) {
|
|
190
|
+
if (reached) return;
|
|
191
|
+
if (actions >= options.maxActions || explored.has(to)) continue;
|
|
192
|
+
if (!(await restore(origin, layer).catch(() => false))) return;
|
|
193
|
+
const target = targetOf(button);
|
|
194
|
+
const back = await device
|
|
195
|
+
.waitFor({ target, timeoutMs: 800 })
|
|
196
|
+
.then(() => device.press(target))
|
|
197
|
+
.then(() => settle())
|
|
198
|
+
.catch(() => undefined);
|
|
199
|
+
if (!back) continue;
|
|
200
|
+
// What is there now, not what was: a banner may have rotated since the sweep.
|
|
201
|
+
await explore(back, depth + 1, into);
|
|
202
|
+
if (!device.server.connected) await recover().catch(() => undefined);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** A route can be jumped to; a layer is opened again from the route under it, the way the edge that found it says. */
|
|
207
|
+
const walkTo = async (node: string): Promise<Screen | undefined> => {
|
|
208
|
+
const [route, layer] = node.split('#');
|
|
209
|
+
if (!route) return undefined;
|
|
210
|
+
const jump = async () => {
|
|
211
|
+
await device.navigate(route).catch(() => undefined);
|
|
212
|
+
await device.idle({ quietMs: 200, timeoutMs: 2000 });
|
|
213
|
+
return settle();
|
|
214
|
+
};
|
|
215
|
+
await revive();
|
|
216
|
+
let now = await jump();
|
|
217
|
+
if (now.route !== route) {
|
|
218
|
+
// A dead navigator answers every jump with the same nothing; one reset is the difference between resuming and not.
|
|
219
|
+
if (!dead(now)) return undefined;
|
|
220
|
+
await revive();
|
|
221
|
+
now = await jump();
|
|
222
|
+
if (now.route !== route) return undefined;
|
|
223
|
+
}
|
|
224
|
+
if (!layer) return now;
|
|
225
|
+
for (const step of options.directions?.(route, node) ?? []) {
|
|
226
|
+
if (!step.press) return undefined;
|
|
227
|
+
const press = step.press;
|
|
228
|
+
const ok = await device
|
|
229
|
+
.waitFor({ target: { testId: press }, timeoutMs: 1000 })
|
|
230
|
+
.then(() => device.press({ testId: press }))
|
|
231
|
+
.then(
|
|
232
|
+
() => true,
|
|
233
|
+
() => false,
|
|
234
|
+
);
|
|
235
|
+
if (!ok) return undefined;
|
|
236
|
+
now = await settle();
|
|
237
|
+
}
|
|
238
|
+
return now.presenting ? now : undefined;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
await clearOverlayIfShown(device, options.overlay);
|
|
242
|
+
const first = await revive();
|
|
243
|
+
start = first.route;
|
|
244
|
+
await explore(first, 0);
|
|
245
|
+
|
|
246
|
+
// The budget is per run and the app is bigger than one: what is left goes back in the frontier, and what
|
|
247
|
+
// is still reachable gets walked to while there is budget.
|
|
248
|
+
while (!reached && actions < options.maxActions) {
|
|
249
|
+
const next = memory?.unfinished().find((node) => !walked.has(node));
|
|
250
|
+
if (!next) break;
|
|
251
|
+
walked.add(next);
|
|
252
|
+
options.log?.(`resuming at ${next}`);
|
|
253
|
+
const there = await walkTo(next);
|
|
254
|
+
if (!there) {
|
|
255
|
+
options.log?.(`could not get back to ${next}`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
await explore(there, 0, next.split('#')[1]);
|
|
259
|
+
}
|
|
260
|
+
fs.writeFileSync(path.join(options.outDir, 'transitions.json'), JSON.stringify({ transitions, states }, null, 2));
|
|
261
|
+
return { transitions, states };
|
|
262
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
import type { Platform } from '../device/devices.js';
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
/** `optional`: a failure is the desired end state too (terminating an app that is not running). */
|
|
12
|
+
type Invocation = { bin: string; args: string[]; optional?: boolean };
|
|
13
|
+
|
|
14
|
+
const IOS_SERVICES = new Set([
|
|
15
|
+
'all',
|
|
16
|
+
'calendar',
|
|
17
|
+
'contacts-limited',
|
|
18
|
+
'contacts',
|
|
19
|
+
'location',
|
|
20
|
+
'location-always',
|
|
21
|
+
'photos-add',
|
|
22
|
+
'photos',
|
|
23
|
+
'media-library',
|
|
24
|
+
'microphone',
|
|
25
|
+
'motion',
|
|
26
|
+
'reminders',
|
|
27
|
+
'siri',
|
|
28
|
+
'camera',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export const simulatorArgv = (platform: Platform, deviceId: string, sub: string, args: string[], appId?: string): Invocation[] => {
|
|
32
|
+
const needApp = (): string => {
|
|
33
|
+
if (!appId) throw new Error(`lynkeus os ${sub} needs --app <bundleId>`);
|
|
34
|
+
return appId;
|
|
35
|
+
};
|
|
36
|
+
if (platform === 'ios') {
|
|
37
|
+
switch (sub) {
|
|
38
|
+
case 'permissions': {
|
|
39
|
+
const [action, service] = args;
|
|
40
|
+
if (!action || !['grant', 'revoke', 'reset'].includes(action)) throw new Error('lynkeus os permissions grant|revoke|reset <service> --app <bundleId>');
|
|
41
|
+
if (!service || !IOS_SERVICES.has(service)) throw new Error(`service must be one of ${[...IOS_SERVICES].join(', ')}`);
|
|
42
|
+
return [{ bin: 'xcrun', args: ['simctl', 'privacy', deviceId, action, service, needApp()] }];
|
|
43
|
+
}
|
|
44
|
+
case 'push': {
|
|
45
|
+
const [payload] = args;
|
|
46
|
+
if (!payload) throw new Error("lynkeus os push '<json>' | <file.json> --app <bundleId>");
|
|
47
|
+
let file = payload;
|
|
48
|
+
if (!fs.existsSync(payload)) {
|
|
49
|
+
file = path.join(os.tmpdir(), `lynkeus-push-${Date.now()}.json`);
|
|
50
|
+
// simctl wants an APNS payload: wrap a bare notification under "aps".
|
|
51
|
+
const parsed = JSON.parse(payload) as Record<string, unknown>;
|
|
52
|
+
fs.writeFileSync(file, JSON.stringify('aps' in parsed ? parsed : { aps: parsed }));
|
|
53
|
+
}
|
|
54
|
+
return [{ bin: 'xcrun', args: ['simctl', 'push', deviceId, needApp(), file] }];
|
|
55
|
+
}
|
|
56
|
+
case 'appearance': {
|
|
57
|
+
const [mode] = args;
|
|
58
|
+
if (mode !== 'light' && mode !== 'dark') throw new Error('lynkeus os appearance light|dark');
|
|
59
|
+
return [{ bin: 'xcrun', args: ['simctl', 'ui', deviceId, 'appearance', mode] }];
|
|
60
|
+
}
|
|
61
|
+
case 'location': {
|
|
62
|
+
const [where] = args;
|
|
63
|
+
if (where === 'clear') return [{ bin: 'xcrun', args: ['simctl', 'location', deviceId, 'clear'] }];
|
|
64
|
+
if (!where || !/^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/.test(where)) throw new Error('lynkeus os location <lat>,<lon> | clear');
|
|
65
|
+
return [{ bin: 'xcrun', args: ['simctl', 'location', deviceId, 'set', where] }];
|
|
66
|
+
}
|
|
67
|
+
case 'language': {
|
|
68
|
+
// No system-wide switch on a booted simulator: relaunch the app with the
|
|
69
|
+
// launch arguments iOS reads for its locale.
|
|
70
|
+
const [tag] = args;
|
|
71
|
+
if (!tag) throw new Error('lynkeus os language <bcp47, e.g. de-DE> --app <bundleId>');
|
|
72
|
+
const app = needApp();
|
|
73
|
+
const locale = tag.replace('-', '_');
|
|
74
|
+
return [
|
|
75
|
+
{ bin: 'xcrun', args: ['simctl', 'terminate', deviceId, app], optional: true },
|
|
76
|
+
{ bin: 'xcrun', args: ['simctl', 'launch', deviceId, app, '-AppleLanguages', `(${tag})`, '-AppleLocale', locale] },
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
throw new Error(`unknown os subcommand "${sub}"; one of permissions, push, appearance, location, language`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const adb = (a: string[]): Invocation => ({ bin: 'adb', args: ['-s', deviceId, ...a] });
|
|
84
|
+
switch (sub) {
|
|
85
|
+
case 'permissions': {
|
|
86
|
+
const [action, service] = args;
|
|
87
|
+
if (!action || !['grant', 'revoke'].includes(action) || !service)
|
|
88
|
+
throw new Error('lynkeus os permissions grant|revoke <android.permission.X> --app <applicationId>');
|
|
89
|
+
return [adb(['shell', 'pm', action, needApp(), service.includes('.') ? service : `android.permission.${service.toUpperCase()}`])];
|
|
90
|
+
}
|
|
91
|
+
case 'appearance': {
|
|
92
|
+
const [mode] = args;
|
|
93
|
+
if (mode !== 'light' && mode !== 'dark') throw new Error('lynkeus os appearance light|dark');
|
|
94
|
+
return [adb(['shell', 'cmd', 'uimode', 'night', mode === 'dark' ? 'yes' : 'no'])];
|
|
95
|
+
}
|
|
96
|
+
case 'location': {
|
|
97
|
+
const [where] = args;
|
|
98
|
+
if (!where || !/^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/.test(where)) throw new Error('lynkeus os location <lat>,<lon>');
|
|
99
|
+
const [lat, lon] = where.split(',');
|
|
100
|
+
return [adb(['emu', 'geo', 'fix', lon!, lat!])];
|
|
101
|
+
}
|
|
102
|
+
default:
|
|
103
|
+
throw new Error(`"${sub}" is not available on Android through lynkeus; permissions, appearance and location are`);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const simulatorCommand = async (platform: Platform, deviceId: string, sub: string, args: string[], appId?: string): Promise<string[]> => {
|
|
108
|
+
const out: string[] = [];
|
|
109
|
+
for (const c of simulatorArgv(platform, deviceId, sub, args, appId)) {
|
|
110
|
+
try {
|
|
111
|
+
const { stdout } = await run(c.bin, c.args);
|
|
112
|
+
out.push(`${c.bin} ${c.args.join(' ')}${stdout.trim() ? `\n${stdout.trim()}` : ''}`);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (c.optional) continue;
|
|
115
|
+
throw new Error(`${c.bin} ${c.args.join(' ')} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
};
|