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.
Files changed (217) hide show
  1. package/lib/__tests__/args.test.d.ts +1 -0
  2. package/lib/__tests__/args.test.js +69 -0
  3. package/lib/__tests__/check.test.d.ts +1 -0
  4. package/lib/__tests__/check.test.js +60 -0
  5. package/lib/__tests__/init.test.d.ts +1 -0
  6. package/lib/__tests__/init.test.js +42 -0
  7. package/lib/__tests__/record.test.d.ts +1 -0
  8. package/lib/__tests__/record.test.js +44 -0
  9. package/lib/__tests__/snapshot.test.d.ts +1 -0
  10. package/lib/__tests__/snapshot.test.js +47 -0
  11. package/lib/__tests__/targets.test.d.ts +1 -0
  12. package/lib/__tests__/targets.test.js +127 -0
  13. package/lib/args.d.ts +30 -0
  14. package/lib/args.js +86 -0
  15. package/lib/cases/__tests__/format.test.d.ts +1 -0
  16. package/lib/cases/__tests__/format.test.js +61 -0
  17. package/lib/cases/__tests__/runner.test.d.ts +1 -0
  18. package/lib/cases/__tests__/runner.test.js +188 -0
  19. package/lib/cases/fixtures.d.ts +13 -0
  20. package/lib/cases/fixtures.js +100 -0
  21. package/lib/cases/format.d.ts +37 -0
  22. package/lib/cases/format.js +169 -0
  23. package/lib/cases/runner.d.ts +61 -0
  24. package/lib/cases/runner.js +590 -0
  25. package/lib/cases/templates.d.ts +20 -0
  26. package/lib/cases/templates.js +156 -0
  27. package/lib/cli.d.ts +6 -0
  28. package/lib/cli.js +173 -0
  29. package/lib/commands/cases.d.ts +1 -0
  30. package/lib/commands/cases.js +179 -0
  31. package/lib/commands/check.d.ts +36 -0
  32. package/lib/commands/check.js +67 -0
  33. package/lib/commands/crawl/return.d.ts +20 -0
  34. package/lib/commands/crawl/return.js +125 -0
  35. package/lib/commands/crawl/state.d.ts +30 -0
  36. package/lib/commands/crawl/state.js +56 -0
  37. package/lib/commands/crawl.d.ts +67 -0
  38. package/lib/commands/crawl.js +213 -0
  39. package/lib/commands/device.d.ts +10 -0
  40. package/lib/commands/device.js +122 -0
  41. package/lib/commands/doctor.d.ts +3 -0
  42. package/lib/commands/doctor.js +240 -0
  43. package/lib/commands/drive.d.ts +1 -0
  44. package/lib/commands/drive.js +410 -0
  45. package/lib/commands/flake.d.ts +14 -0
  46. package/lib/commands/flake.js +42 -0
  47. package/lib/commands/headless.d.ts +1 -0
  48. package/lib/commands/headless.js +127 -0
  49. package/lib/commands/init.d.ts +13 -0
  50. package/lib/commands/init.js +85 -0
  51. package/lib/commands/map.d.ts +2 -0
  52. package/lib/commands/map.js +297 -0
  53. package/lib/commands/os.d.ts +1 -0
  54. package/lib/commands/os.js +193 -0
  55. package/lib/commands/overlay.d.ts +9 -0
  56. package/lib/commands/overlay.js +12 -0
  57. package/lib/commands/record.d.ts +5 -0
  58. package/lib/commands/record.js +55 -0
  59. package/lib/commands/runs.d.ts +1 -0
  60. package/lib/commands/runs.js +452 -0
  61. package/lib/commands/smoke.d.ts +28 -0
  62. package/lib/commands/smoke.js +59 -0
  63. package/lib/commands/snapshot.d.ts +27 -0
  64. package/lib/commands/snapshot.js +69 -0
  65. package/lib/config.d.ts +72 -0
  66. package/lib/config.js +44 -0
  67. package/lib/device/devices.d.ts +11 -0
  68. package/lib/device/devices.js +33 -0
  69. package/lib/device/driver.d.ts +53 -0
  70. package/lib/device/driver.js +169 -0
  71. package/lib/device/platform.d.ts +7 -0
  72. package/lib/device/platform.js +59 -0
  73. package/lib/files.d.ts +7 -0
  74. package/lib/files.js +29 -0
  75. package/lib/flow/artifacts.d.ts +19 -0
  76. package/lib/flow/artifacts.js +49 -0
  77. package/lib/flow/runner.d.ts +13 -0
  78. package/lib/flow/runner.js +169 -0
  79. package/lib/flow/types.d.ts +111 -0
  80. package/lib/flow/types.js +1 -0
  81. package/lib/index.d.ts +18 -0
  82. package/lib/index.js +11 -0
  83. package/lib/knowledge/__tests__/edges.test.d.ts +1 -0
  84. package/lib/knowledge/__tests__/edges.test.js +25 -0
  85. package/lib/knowledge/__tests__/events.test.d.ts +1 -0
  86. package/lib/knowledge/__tests__/events.test.js +39 -0
  87. package/lib/knowledge/__tests__/extract.test.d.ts +1 -0
  88. package/lib/knowledge/__tests__/extract.test.js +69 -0
  89. package/lib/knowledge/__tests__/frontier.test.d.ts +1 -0
  90. package/lib/knowledge/__tests__/frontier.test.js +57 -0
  91. package/lib/knowledge/__tests__/funnels.test.d.ts +1 -0
  92. package/lib/knowledge/__tests__/funnels.test.js +40 -0
  93. package/lib/knowledge/__tests__/lint.test.d.ts +1 -0
  94. package/lib/knowledge/__tests__/lint.test.js +53 -0
  95. package/lib/knowledge/__tests__/perf.test.d.ts +1 -0
  96. package/lib/knowledge/__tests__/perf.test.js +34 -0
  97. package/lib/knowledge/__tests__/plan.test.d.ts +1 -0
  98. package/lib/knowledge/__tests__/plan.test.js +35 -0
  99. package/lib/knowledge/__tests__/query.test.d.ts +1 -0
  100. package/lib/knowledge/__tests__/query.test.js +55 -0
  101. package/lib/knowledge/__tests__/screens.test.d.ts +1 -0
  102. package/lib/knowledge/__tests__/screens.test.js +30 -0
  103. package/lib/knowledge/__tests__/why.test.d.ts +1 -0
  104. package/lib/knowledge/__tests__/why.test.js +28 -0
  105. package/lib/knowledge/edges.d.ts +30 -0
  106. package/lib/knowledge/edges.js +68 -0
  107. package/lib/knowledge/errors.d.ts +8 -0
  108. package/lib/knowledge/errors.js +62 -0
  109. package/lib/knowledge/events.d.ts +23 -0
  110. package/lib/knowledge/events.js +51 -0
  111. package/lib/knowledge/extract.d.ts +26 -0
  112. package/lib/knowledge/extract.js +365 -0
  113. package/lib/knowledge/frontier.d.ts +26 -0
  114. package/lib/knowledge/frontier.js +60 -0
  115. package/lib/knowledge/funnels.d.ts +46 -0
  116. package/lib/knowledge/funnels.js +103 -0
  117. package/lib/knowledge/lint.d.ts +16 -0
  118. package/lib/knowledge/lint.js +89 -0
  119. package/lib/knowledge/perf.d.ts +32 -0
  120. package/lib/knowledge/perf.js +91 -0
  121. package/lib/knowledge/plan.d.ts +22 -0
  122. package/lib/knowledge/plan.js +81 -0
  123. package/lib/knowledge/problems.d.ts +5 -0
  124. package/lib/knowledge/problems.js +8 -0
  125. package/lib/knowledge/query.d.ts +36 -0
  126. package/lib/knowledge/query.js +127 -0
  127. package/lib/knowledge/route-params.d.ts +6 -0
  128. package/lib/knowledge/route-params.js +42 -0
  129. package/lib/knowledge/screens.d.ts +30 -0
  130. package/lib/knowledge/screens.js +125 -0
  131. package/lib/knowledge/tree.d.ts +15 -0
  132. package/lib/knowledge/tree.js +63 -0
  133. package/lib/knowledge/types.d.ts +62 -0
  134. package/lib/knowledge/types.js +6 -0
  135. package/lib/knowledge/why.d.ts +24 -0
  136. package/lib/knowledge/why.js +153 -0
  137. package/lib/mcp/server.d.ts +4 -0
  138. package/lib/mcp/server.js +111 -0
  139. package/lib/registry.d.ts +78 -0
  140. package/lib/registry.js +113 -0
  141. package/lib/runtime.d.ts +12 -0
  142. package/lib/runtime.js +56 -0
  143. package/lib/targets.d.ts +17 -0
  144. package/lib/targets.js +43 -0
  145. package/package.json +47 -0
  146. package/src/__tests__/args.test.ts +82 -0
  147. package/src/__tests__/check.test.ts +71 -0
  148. package/src/__tests__/init.test.ts +52 -0
  149. package/src/__tests__/record.test.ts +49 -0
  150. package/src/__tests__/snapshot.test.ts +51 -0
  151. package/src/__tests__/targets.test.ts +132 -0
  152. package/src/args.ts +111 -0
  153. package/src/cases/__tests__/format.test.ts +76 -0
  154. package/src/cases/__tests__/runner.test.ts +230 -0
  155. package/src/cases/fixtures.ts +104 -0
  156. package/src/cases/format.ts +183 -0
  157. package/src/cases/runner.ts +575 -0
  158. package/src/cases/templates.ts +159 -0
  159. package/src/cli.ts +176 -0
  160. package/src/commands/cases.ts +170 -0
  161. package/src/commands/check.ts +83 -0
  162. package/src/commands/crawl/return.ts +130 -0
  163. package/src/commands/crawl/state.ts +70 -0
  164. package/src/commands/crawl.ts +262 -0
  165. package/src/commands/device.ts +119 -0
  166. package/src/commands/doctor.ts +237 -0
  167. package/src/commands/drive.ts +413 -0
  168. package/src/commands/flake.ts +48 -0
  169. package/src/commands/headless.ts +131 -0
  170. package/src/commands/init.ts +98 -0
  171. package/src/commands/map.ts +298 -0
  172. package/src/commands/os.ts +195 -0
  173. package/src/commands/overlay.ts +20 -0
  174. package/src/commands/record.ts +46 -0
  175. package/src/commands/runs.ts +475 -0
  176. package/src/commands/smoke.ts +86 -0
  177. package/src/commands/snapshot.ts +87 -0
  178. package/src/config.ts +103 -0
  179. package/src/device/devices.ts +44 -0
  180. package/src/device/driver.ts +197 -0
  181. package/src/device/platform.ts +74 -0
  182. package/src/files.ts +35 -0
  183. package/src/flow/artifacts.ts +61 -0
  184. package/src/flow/runner.ts +182 -0
  185. package/src/flow/types.ts +55 -0
  186. package/src/index.ts +18 -0
  187. package/src/knowledge/__tests__/edges.test.ts +34 -0
  188. package/src/knowledge/__tests__/events.test.ts +45 -0
  189. package/src/knowledge/__tests__/extract.test.ts +88 -0
  190. package/src/knowledge/__tests__/frontier.test.ts +77 -0
  191. package/src/knowledge/__tests__/funnels.test.ts +58 -0
  192. package/src/knowledge/__tests__/lint.test.ts +66 -0
  193. package/src/knowledge/__tests__/perf.test.ts +41 -0
  194. package/src/knowledge/__tests__/plan.test.ts +52 -0
  195. package/src/knowledge/__tests__/query.test.ts +74 -0
  196. package/src/knowledge/__tests__/screens.test.ts +37 -0
  197. package/src/knowledge/__tests__/why.test.ts +40 -0
  198. package/src/knowledge/edges.ts +91 -0
  199. package/src/knowledge/errors.ts +73 -0
  200. package/src/knowledge/events.ts +60 -0
  201. package/src/knowledge/extract.ts +379 -0
  202. package/src/knowledge/frontier.ts +82 -0
  203. package/src/knowledge/funnels.ts +126 -0
  204. package/src/knowledge/lint.ts +99 -0
  205. package/src/knowledge/perf.ts +114 -0
  206. package/src/knowledge/plan.ts +95 -0
  207. package/src/knowledge/problems.ts +12 -0
  208. package/src/knowledge/query.ts +146 -0
  209. package/src/knowledge/route-params.ts +39 -0
  210. package/src/knowledge/screens.ts +139 -0
  211. package/src/knowledge/tree.ts +66 -0
  212. package/src/knowledge/types.ts +60 -0
  213. package/src/knowledge/why.ts +189 -0
  214. package/src/mcp/server.ts +109 -0
  215. package/src/registry.ts +191 -0
  216. package/src/runtime.ts +54 -0
  217. package/src/targets.ts +44 -0
package/src/config.ts ADDED
@@ -0,0 +1,103 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import type { CasesConfig } from './cases/runner.js';
5
+ import { readJson } from './files.js';
6
+ import type { ScreenGraph } from './knowledge/types.js';
7
+
8
+ export const lynkeusDir = (root = process.cwd()) => path.join(root, '.lynkeus');
9
+ export const graphFile = (root?: string) => path.join(lynkeusDir(root), 'graph.json');
10
+ export const edgesFile = (root?: string) => path.join(lynkeusDir(root), 'edges.jsonl');
11
+ export const frontierFile = (root?: string) => path.join(lynkeusDir(root), 'frontier.jsonl');
12
+ export const runsDir = (root?: string) => path.join(lynkeusDir(root), 'runs');
13
+
14
+ export const loadGraph = (root?: string): ScreenGraph | null => {
15
+ const file = graphFile(root);
16
+ return fs.existsSync(file) ? readJson<ScreenGraph>(file) : null;
17
+ };
18
+
19
+ /**
20
+ * `lynkeus.config.json` in the app's folder: what would otherwise be repeated
21
+ * as flags or environment on every invocation. Flags win over the
22
+ * environment, the environment over the file.
23
+ */
24
+ export type LynkeusConfig = {
25
+ cases?: CasesConfig;
26
+ /** The headless entry (default `qa/headless.tsx`). */
27
+ entry?: string;
28
+ /** The environment variable the app reads its API base from; the headless entry refuses one that is not local. */
29
+ apiEnv?: string;
30
+ /** The driver port the app dials. */
31
+ port?: number;
32
+ /** Simulator udid or name. */
33
+ device?: string;
34
+ /** Bundle id or application id. */
35
+ app?: string;
36
+ /**
37
+ * The language this app speaks, passed on and never interpreted: `os
38
+ * language` defaults to it, the headless entry mocks this locale, and an MCP
39
+ * client is told, so a model reading the screens knows their language.
40
+ */
41
+ language?: string;
42
+ locale?: { languageTag?: string; country?: string };
43
+ /** How long to wait for the app to attach, in ms. */
44
+ attachTimeout?: number;
45
+ /** The lint baseline `lint` compares against when no file is given. */
46
+ lintBaseline?: string;
47
+ /** Regular expressions for the text this app shows when it is broken; `smoke` and `crawl` report a match. */
48
+ errorCopy?: string[];
49
+ /** The shape of an untranslated key, and the smallest acceptable touch target in points. */
50
+ lint?: { i18nKeyPatterns?: string[]; minTouchTarget?: number };
51
+ /**
52
+ * Regular expressions for the testIDs of controls whose content changes on
53
+ * its own (a ticking clock, a countdown). Their text stops counting as a
54
+ * commit, or `idle` would wait out its whole timeout on every screen that
55
+ * shows one.
56
+ */
57
+ live?: string[];
58
+ /** Keep React.StrictMode in the headless host. Off by default: its double renders inflate what the host measures. */
59
+ strictMode?: boolean;
60
+ /** Regular expressions matched against a control's testID and label. lynkeus ships none: only the app knows which controls are irreversible. */
61
+ crawl?: {
62
+ /** Never pressed. */
63
+ never?: string[];
64
+ /** Close an overlay the app raises. */
65
+ close?: string[];
66
+ /** Go back. Without them the crawl takes a small control in the top-left corner for one. */
67
+ back?: string[];
68
+ };
69
+ };
70
+
71
+ export const configFile = (root?: string) => path.join(root ?? process.cwd(), 'lynkeus.config.json');
72
+
73
+ const readConfig = (file: string): LynkeusConfig => {
74
+ if (!fs.existsSync(file)) return {};
75
+ try {
76
+ const config = readJson<LynkeusConfig>(file);
77
+ // Paths under `cases` are relative to the file that declares them.
78
+ if (config.cases) config.cases.base = path.dirname(path.resolve(file));
79
+ return config;
80
+ } catch (error) {
81
+ throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
82
+ }
83
+ };
84
+
85
+ /** The app's configuration, under the one `LYNKEUS_CONFIG` names: cases and fixtures often live in a repository of their own. */
86
+ export const loadConfig = (root?: string): LynkeusConfig => {
87
+ const own = readConfig(configFile(root));
88
+ const extra = process.env.LYNKEUS_CONFIG ? readConfig(path.resolve(process.env.LYNKEUS_CONFIG)) : {};
89
+ return { ...own, ...extra };
90
+ };
91
+
92
+ export const portFromEnv = (config: LynkeusConfig = {}): number | undefined => (process.env.LYNKEUS_PORT ? Number(process.env.LYNKEUS_PORT) : config.port);
93
+
94
+ /** A list of regular expressions from the configuration, as one that matches any of them. */
95
+ export const anyOf = (patterns: string[] | undefined): RegExp | undefined => (patterns?.length ? new RegExp(patterns.join('|'), 'i') : undefined);
96
+
97
+ export const packageName = (root: string): string => {
98
+ try {
99
+ return (JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as { name?: string }).name ?? path.basename(root);
100
+ } catch {
101
+ return path.basename(root);
102
+ }
103
+ };
@@ -0,0 +1,44 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const run = promisify(execFile);
5
+
6
+ export type Platform = 'ios' | 'android';
7
+
8
+ export type DeviceInfo = {
9
+ id: string;
10
+ name: string;
11
+ platform: Platform;
12
+ type: 'simulator' | 'emulator' | 'physical';
13
+ connected: boolean;
14
+ };
15
+
16
+ export const bootedSimulators = async (): Promise<DeviceInfo[]> => {
17
+ // No Xcode (Linux, CI) means no simulators, not a crash: the app may be hosted headless.
18
+ let stdout: string;
19
+ try {
20
+ ({ stdout } = await run('xcrun', ['simctl', 'list', 'devices', 'booted', '-j']));
21
+ } catch (error) {
22
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
23
+ throw error;
24
+ }
25
+ const parsed = JSON.parse(stdout) as { devices: Record<string, { udid: string; name: string; state: string }[]> };
26
+ return Object.values(parsed.devices)
27
+ .flat()
28
+ .filter((d) => d.state === 'Booted')
29
+ .map((d) => ({ id: d.udid, name: d.name, platform: 'ios', type: 'simulator', connected: true }));
30
+ };
31
+
32
+ export const HEADLESS_HOST: DeviceInfo = { id: 'headless', name: 'headless host', platform: 'ios', type: 'simulator', connected: true };
33
+
34
+ export const pickAgentDevice = async (wanted?: string): Promise<DeviceInfo> => {
35
+ const devices = await bootedSimulators();
36
+ const id = wanted ?? process.env.LYNKEUS_DEVICE;
37
+ const device = id ? devices.find((d) => d.id === id || d.name.includes(id)) : devices[0];
38
+ if (!device) {
39
+ // A headless host dials the driver itself: nothing to boot or attach to.
40
+ if (!id && devices.length === 0) return HEADLESS_HOST;
41
+ throw new Error(id ? `No booted simulator matching "${id}".` : 'No booted simulator. Boot one first.');
42
+ }
43
+ return device;
44
+ };
@@ -0,0 +1,197 @@
1
+ /**
2
+ * An app running the agent. The screen is the React tree the app hands over and
3
+ * touches are synthesized inside its process, so an action costs milliseconds;
4
+ * only launching, screenshots and the status bar go through simctl or adb.
5
+ */
6
+ import { AgentServer } from 'lynkeus-client';
7
+ import type { CoreMethods, Screen, TraceEvent } from 'lynkeus-protocol';
8
+
9
+ import { portFromEnv } from '../config.js';
10
+ import { withoutHost } from '../knowledge/problems.js';
11
+ import type { DeviceInfo } from './devices.js';
12
+ import { lifecycleFor, type PlatformLifecycle } from './platform.js';
13
+
14
+ export class Device {
15
+ info: DeviceInfo;
16
+ readonly server: AgentServer;
17
+
18
+ #lifecycle: PlatformLifecycle;
19
+ #appId: string | null;
20
+ #resolving?: Promise<DeviceInfo>;
21
+
22
+ constructor(info: DeviceInfo, options: { server?: AgentServer; appId?: string | null; resolving?: Promise<DeviceInfo> } = {}) {
23
+ this.info = info;
24
+ this.#resolving = options.resolving;
25
+ // The app serves commands only to a driver that presents its token; without one, anything on the loopback could drive a dev build.
26
+ this.server = options.server ?? new AgentServer({ token: process.env.LYNKEUS_TOKEN, port: portFromEnv() });
27
+ this.#lifecycle = lifecycleFor(info.platform);
28
+ this.#appId = options.appId ?? null;
29
+ }
30
+
31
+ /** The device as the OS knows it: reading the screen needs none of it; launching, screenshots and OS commands do. */
32
+ async resolved(): Promise<DeviceInfo> {
33
+ if (this.#resolving) {
34
+ this.info = await this.#resolving;
35
+ this.#resolving = undefined;
36
+ }
37
+ return this.info;
38
+ }
39
+
40
+ get appId(): string | null {
41
+ return this.#appId;
42
+ }
43
+
44
+ async stop(): Promise<void> {
45
+ await this.server.close();
46
+ }
47
+
48
+ async launchApp(appId: string, options: { fresh?: boolean } = {}): Promise<void> {
49
+ this.#appId = appId;
50
+ await this.server.listen();
51
+ await this.#lifecycle.launchApp((await this.resolved()).id, appId, options.fresh ?? true);
52
+ await this.server.waitForApp();
53
+ }
54
+
55
+ async openUrl(url: string): Promise<void> {
56
+ await this.server.call('deepLink', { url });
57
+ }
58
+
59
+ /** Pins the clock, battery and radios so a pixel baseline survives the calendar. */
60
+ async freezeChrome(): Promise<void> {
61
+ await this.#lifecycle.freezeChrome((await this.resolved()).id);
62
+ }
63
+
64
+ async screenshot(destination: string): Promise<void> {
65
+ await this.#lifecycle.screenshot((await this.resolved()).id, destination);
66
+ }
67
+
68
+ screen(): Promise<Screen> {
69
+ return this.server.call('screen');
70
+ }
71
+
72
+ press(params: CoreMethods['press']['params']): Promise<CoreMethods['press']['result']> {
73
+ return this.server.call('press', params);
74
+ }
75
+
76
+ type(params: CoreMethods['type']['params']): Promise<CoreMethods['type']['result']> {
77
+ return this.server.call('type', params);
78
+ }
79
+
80
+ swipe(params: CoreMethods['swipe']['params']): Promise<CoreMethods['swipe']['result']> {
81
+ return this.server.call('swipe', params);
82
+ }
83
+
84
+ back(): Promise<CoreMethods['back']['result']> {
85
+ return this.server.call('back');
86
+ }
87
+
88
+ async navigate(name: string, params?: object): Promise<void> {
89
+ await this.server.call('navigate', { name, params });
90
+ }
91
+
92
+ // The agent's own timeout governs; the transport waits a little longer so it hears the agent's answer.
93
+ waitFor(params: CoreMethods['waitFor']['params']): Promise<CoreMethods['waitFor']['result']> {
94
+ return this.server.call('waitFor', params, (params.timeoutMs ?? 5000) + 2000);
95
+ }
96
+
97
+ idle(params?: CoreMethods['idle']['params']): Promise<CoreMethods['idle']['result']> {
98
+ return this.server.call('idle', params, (params?.timeoutMs ?? 3000) + 2000);
99
+ }
100
+
101
+ requests(since?: number): Promise<CoreMethods['requests']['result']> {
102
+ return this.server.call('requests', { since });
103
+ }
104
+
105
+ trace(since?: number): Promise<CoreMethods['trace']['result']> {
106
+ return this.server.call('trace', { since });
107
+ }
108
+
109
+ /** A command the app registered with `qa.register`. */
110
+ command<T = unknown>(name: string, params?: unknown): Promise<T> {
111
+ return this.server.call<T>(name, params);
112
+ }
113
+ }
114
+
115
+ const quote = (value: string) => JSON.stringify(value);
116
+
117
+ export const describeTrace = (events: TraceEvent[]): string => {
118
+ if (events.length === 0) return '(no events)';
119
+ const t0 = events[0]!.t;
120
+ const rel = (t: number) => `+${String(t - t0).padStart(6)}ms`;
121
+ return events
122
+ .map((e) => {
123
+ if (e.kind === 'route') return `${rel(e.t)} route ${e.route ?? '?'} (${e.path.join(' > ')})`;
124
+ if (e.kind === 'request')
125
+ return `${rel(e.t)} request ${String(e.status ?? '…').padStart(3)} ${e.method.padEnd(5)} ${withoutHost(e.url)}${e.error ? ` (${e.error})` : ''}`;
126
+ if (e.kind === 'stall')
127
+ return `${rel(e.t)} stall render loop broken: ${e.renders} updates${e.component ? ` to ${e.component}` : ''} in ${e.windowMs}ms`;
128
+ if (e.kind === 'touch')
129
+ return `${rel(e.t)} touch ${e.testId ? `#${e.testId}` : e.text ? JSON.stringify(e.text) : `${e.x},${e.y}`} on ${e.route ?? '?'}`;
130
+ if (e.kind === 'crash') return `${rel(e.t)} crash ${e.message}`;
131
+ if (e.kind === 'event') return `${rel(e.t)} event ${e.name}${e.props ? ` ${JSON.stringify(e.props)}` : ''}`;
132
+ if (e.kind === 'store')
133
+ return `${rel(e.t)} store ${e.store}: ${Object.entries(e.changed)
134
+ .map(([k, v]) => `${k} ${JSON.stringify(v.from)}→${JSON.stringify(v.to)}`)
135
+ .join(', ')}`;
136
+ return `${rel(e.t)} command ${e.method} ${e.ms}ms${e.error ? ` ✗ ${e.error}` : ''}`;
137
+ })
138
+ .join('\n');
139
+ };
140
+
141
+ type DescribeOptions = {
142
+ limit?: number;
143
+ /** Also what the platform is not presenting: behind a layer, out of the accessibility tree. */
144
+ all?: boolean;
145
+ /** Each element's centre, in points. */
146
+ frames?: boolean;
147
+ /** The commit count and the read's cost in the header. */
148
+ verbose?: boolean;
149
+ };
150
+
151
+ export const describeScreen = (screen: Screen, options: DescribeOptions = {}): string => {
152
+ const { limit = 80, all = false, frames = false, verbose = false } = options;
153
+ const layer = screen.presenting ? ` +${screen.presenting.modal ? 'modal' : 'overlay'}${screen.presenting.testId ? `#${screen.presenting.testId}` : ''}` : '';
154
+ // Plain views are layout; one that names itself (a testID, a label) is content.
155
+ const named = screen.elements.filter((e) => e.kind !== 'view' || e.accessibilityLabel !== undefined || e.testId !== undefined);
156
+ // What sits inside a button is the button: its icon is not something else to read or press.
157
+ const insideButton = (e: Screen['elements'][number]) => {
158
+ for (let p = e.parent; p !== undefined; p = screen.elements[p]?.parent) if (screen.elements[p]?.kind === 'button') return true;
159
+ return false;
160
+ };
161
+ // An inert control is still read, even though it cannot be touched.
162
+ const listed = all ? named : named.filter((e) => (!e.hidden || e.hidden === 'inert') && !(e.kind === 'view' && insideButton(e)));
163
+ const counts = new Map<string, number>();
164
+ if (!all) for (const e of named) if (e.hidden && e.hidden !== 'inert') counts.set(e.hidden, (counts.get(e.hidden) ?? 0) + 1);
165
+ const hiddenTotal = [...counts.values()].reduce((sum, n) => sum + n, 0);
166
+ const hiddenNote = hiddenTotal ? ` · ${hiddenTotal} hidden: ${[...counts].map(([reason, n]) => `${n} ${reason}`).join(', ')}` : '';
167
+ const stats = verbose ? ` commit=${screen.commit}${screen.busy ? ' busy' : ''} ${screen.costMs}ms` : screen.busy ? ' busy' : '';
168
+ // The path says which navigators hold the route; alone, it only repeats it.
169
+ const trail = screen.path.join(' > ');
170
+ const header = `${screen.route ?? '?'}${trail === screen.route ? '' : ` (${trail})`}${layer}${stats}${hiddenNote}`;
171
+ // An index is how to point at what has no name of its own, or shares it: a control
172
+ // with a testID nothing else carries is pointed at by that.
173
+ const nameOf = (e: Screen['elements'][number]) => (e.testId ? `#${e.testId}` : (e.text ?? e.accessibilityLabel));
174
+ const uses = new Map<string, number>();
175
+ for (const e of listed) {
176
+ const name = nameOf(e);
177
+ if (name !== undefined) uses.set(name, (uses.get(name) ?? 0) + 1);
178
+ }
179
+ const lines = listed.slice(0, limit).map((e) => {
180
+ const id = e.testId ? ` #${e.testId}` : '';
181
+ const name = nameOf(e);
182
+ const index = name !== undefined && uses.get(name) === 1 ? '' : `${e.i} `;
183
+ // A label that only repeats the testID says nothing new.
184
+ const label = (e.text ?? e.accessibilityLabel) === e.testId ? undefined : (e.text ?? e.accessibilityLabel);
185
+ const value =
186
+ e.kind === 'input'
187
+ ? ` value=${quote(e.value ?? '')}${e.placeholder ? ` placeholder=${quote(e.placeholder)}` : ''}${e.focused ? ' *focused' : ''}`
188
+ : e.value !== undefined
189
+ ? ` value=${quote(e.value)}` // a QR's encoded string, say
190
+ : '';
191
+ const state = `${e.enabled ? '' : ' !disabled'}${e.covered ? ' ~covered' : ''}${e.hidden ? ` ~${e.hidden}` : ''}`;
192
+ const at = frames ? ` @${Math.round(e.frame.x + e.frame.w / 2)},${Math.round(e.frame.y + e.frame.h / 2)}` : '';
193
+ return `${index}${e.kind}${id}${label ? ` ${quote(label)}` : ''}${value}${state}${at}`;
194
+ });
195
+ const rest = listed.length - lines.length;
196
+ return [header, ...lines, rest > 0 ? `… ${rest} more` : ''].filter(Boolean).join('\n');
197
+ };
@@ -0,0 +1,74 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { promisify } from 'node:util';
4
+
5
+ import type { Platform } from './devices.js';
6
+
7
+ const run = promisify(execFile);
8
+
9
+ export type PlatformLifecycle = {
10
+ launchApp(deviceId: string, appId: string, fresh: boolean): Promise<void>;
11
+ screenshot(deviceId: string, destination: string): Promise<void>;
12
+ freezeChrome(deviceId: string): Promise<void>;
13
+ };
14
+
15
+ const ios: PlatformLifecycle = {
16
+ async launchApp(deviceId, appId, fresh) {
17
+ const args = ['simctl', 'launch'];
18
+ if (fresh) args.push('--terminate-running-process');
19
+ await run('xcrun', [...args, deviceId, appId]);
20
+ },
21
+
22
+ async screenshot(deviceId, destination) {
23
+ await run('xcrun', ['simctl', 'io', deviceId, 'screenshot', destination]);
24
+ },
25
+
26
+ async freezeChrome(deviceId) {
27
+ await run('xcrun', [
28
+ 'simctl',
29
+ 'status_bar',
30
+ deviceId,
31
+ 'override',
32
+ '--time',
33
+ '9:41',
34
+ '--batteryState',
35
+ 'charged',
36
+ '--batteryLevel',
37
+ '100',
38
+ '--cellularMode',
39
+ 'active',
40
+ '--cellularBars',
41
+ '4',
42
+ '--wifiMode',
43
+ 'active',
44
+ '--wifiBars',
45
+ '3',
46
+ ]);
47
+ },
48
+ };
49
+
50
+ const adb = (deviceId: string, args: string[]) => run('adb', ['-s', deviceId, ...args]);
51
+
52
+ const android: PlatformLifecycle = {
53
+ async launchApp(deviceId, appId, fresh) {
54
+ if (fresh) await adb(deviceId, ['shell', 'am', 'force-stop', appId]);
55
+ await adb(deviceId, ['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1']);
56
+ },
57
+
58
+ async screenshot(deviceId, destination) {
59
+ const { stdout } = await run('adb', ['-s', deviceId, 'exec-out', 'screencap', '-p'], { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 });
60
+ await writeFile(destination, stdout);
61
+ },
62
+
63
+ async freezeChrome(deviceId) {
64
+ await adb(deviceId, ['shell', 'settings', 'put', 'global', 'sysui_demo_allowed', '1']);
65
+ const demo = (args: string[]) => adb(deviceId, ['shell', 'am', 'broadcast', '-a', 'com.android.systemui.demo', ...args]);
66
+ await demo(['-e', 'command', 'enter']);
67
+ await demo(['-e', 'command', 'clock', '-e', 'hhmm', '0941']);
68
+ await demo(['-e', 'command', 'battery', '-e', 'level', '100', '-e', 'plugged', 'false']);
69
+ await demo(['-e', 'command', 'network', '-e', 'wifi', 'show', '-e', 'level', '4']);
70
+ await demo(['-e', 'command', 'notifications', '-e', 'visible', 'false']);
71
+ },
72
+ };
73
+
74
+ export const lifecycleFor = (platform: Platform): PlatformLifecycle => (platform === 'ios' ? ios : android);
package/src/files.ts ADDED
@@ -0,0 +1,35 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const readJson = <T>(file: string): T => JSON.parse(fs.readFileSync(file, 'utf8')) as T;
5
+
6
+ export const writeJson = (file: string, value: unknown): void => {
7
+ fs.mkdirSync(path.dirname(file), { recursive: true });
8
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
9
+ };
10
+
11
+ export const readJsonl = <T>(file: string): T[] => {
12
+ if (!fs.existsSync(file)) return [];
13
+ return fs
14
+ .readFileSync(file, 'utf8')
15
+ .split('\n')
16
+ .filter((line) => line.trim())
17
+ .map((line) => JSON.parse(line) as T);
18
+ };
19
+
20
+ export const appendJsonl = (file: string, row: unknown): void => {
21
+ fs.mkdirSync(path.dirname(file), { recursive: true });
22
+ fs.appendFileSync(file, `${JSON.stringify(row)}\n`);
23
+ };
24
+
25
+ // Through a temporary file and a rename, so a reader never sees half a file.
26
+ export const writeJsonl = (file: string, rows: unknown[]): void => {
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ const tmp = `${file}.${process.pid}.tmp`;
29
+ fs.writeFileSync(tmp, rows.map((row) => `${JSON.stringify(row)}\n`).join(''));
30
+ fs.renameSync(tmp, file);
31
+ };
32
+
33
+ export const today = () => new Date().toISOString().slice(0, 10);
34
+
35
+ export const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
@@ -0,0 +1,61 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import type { Element, Screen } from 'lynkeus-protocol';
5
+
6
+ import { stamp } from '../files.js';
7
+
8
+ export const runDir = (root: string, flowName: string): string => {
9
+ const slug = flowName
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/^-|-$/g, '');
13
+ const dir = path.join(root, slug, stamp());
14
+ fs.mkdirSync(dir, { recursive: true });
15
+ return dir;
16
+ };
17
+
18
+ export const stepFile = (dir: string, index: number, extension: string) => path.join(dir, `step-${String(index).padStart(2, '0')}.${extension}`);
19
+
20
+ const key = (e: Element) => `${e.kind}|${e.testId ?? ''}|${e.text ?? e.accessibilityLabel ?? ''}`;
21
+
22
+ export type ScreenDiff = {
23
+ route: { before?: string; after?: string; changed: boolean };
24
+ added: Element[];
25
+ removed: Element[];
26
+ changedText: { before: Element; after: Element }[];
27
+ };
28
+
29
+ /** Structural difference between two screens: no geometry, so layout jitter is not a change. */
30
+ export const diffScreens = (before: Screen, after: Screen): ScreenDiff => {
31
+ const beforeByKey = new Map(before.elements.map((e) => [key(e), e]));
32
+ const afterByKey = new Map(after.elements.map((e) => [key(e), e]));
33
+ const added = after.elements.filter((e) => !beforeByKey.has(key(e)));
34
+ const removed = before.elements.filter((e) => !afterByKey.has(key(e)));
35
+
36
+ // Same identity (testId) but different text: a value or a label changed.
37
+ const changedText: ScreenDiff['changedText'] = [];
38
+ const removedById = new Map(removed.filter((e) => e.testId).map((e) => [e.testId!, e]));
39
+ for (const e of added) {
40
+ const previous = e.testId ? removedById.get(e.testId) : undefined;
41
+ if (previous) changedText.push({ before: previous, after: e });
42
+ }
43
+ const paired = new Set(changedText.flatMap((c) => [c.before, c.after]));
44
+ return {
45
+ route: { before: before.route, after: after.route, changed: before.route !== after.route },
46
+ added: added.filter((e) => !paired.has(e)),
47
+ removed: removed.filter((e) => !paired.has(e)),
48
+ changedText,
49
+ };
50
+ };
51
+
52
+ const label = (e: Element) => `${e.kind} ${e.testId ? `#${e.testId}` : ''} ${e.text ? JSON.stringify(e.text.slice(0, 60)) : ''}`.trim();
53
+
54
+ export const describeDiff = (diff: ScreenDiff): string => {
55
+ const lines: string[] = [];
56
+ if (diff.route.changed) lines.push(`route ${diff.route.before ?? '?'} → ${diff.route.after ?? '?'}`);
57
+ for (const c of diff.changedText) lines.push(`~ ${label(c.before)} → ${JSON.stringify(c.after.text ?? '')}`);
58
+ for (const e of diff.added) lines.push(`+ ${label(e)}`);
59
+ for (const e of diff.removed) lines.push(`- ${label(e)}`);
60
+ return lines.length ? lines.join('\n') : '(no structural change)';
61
+ };