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
@@ -0,0 +1,169 @@
1
+ import path from 'node:path';
2
+ import { writeJson } from '../files.js';
3
+ import { drainEvents, writeEvents } from '../knowledge/events.js';
4
+ import { runDir, stepFile } from './artifacts.js';
5
+ const interpolate = (value, vars) => {
6
+ if (typeof value === 'string') {
7
+ return value.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => {
8
+ const found = key.split('.').reduce((acc, part) => acc?.[part], vars);
9
+ if (found === undefined)
10
+ throw new Error(`Unknown flow variable {{${key}}}`);
11
+ return String(found);
12
+ });
13
+ }
14
+ if (Array.isArray(value))
15
+ return value.map((v) => interpolate(v, vars));
16
+ if (value && typeof value === 'object') {
17
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, interpolate(v, vars)]));
18
+ }
19
+ return value;
20
+ };
21
+ export const runFlow = async (flow, options) => {
22
+ const { device } = options;
23
+ const vars = { ...options.vars };
24
+ const missing = (flow.vars ?? []).filter((name) => vars[name] === undefined);
25
+ if (missing.length)
26
+ throw new Error(`Flow "${flow.name}" needs vars: ${missing.join(', ')}`);
27
+ const reports = [];
28
+ const started = performance.now();
29
+ const dir = options.artifactsRoot ? runDir(options.artifactsRoot, flow.name) : undefined;
30
+ const runEvents = [];
31
+ // What the app recorded before this run is not this run's.
32
+ if (options.events && device.server.connected)
33
+ await drainEvents(device);
34
+ for (const [index, raw] of flow.steps.entries()) {
35
+ const step = interpolate(raw, vars);
36
+ const t0 = performance.now();
37
+ const report = { step, ok: true, ms: 0 };
38
+ try {
39
+ await execute(step, flow, device, vars);
40
+ }
41
+ catch (error) {
42
+ report.ok = false;
43
+ report.error = error instanceof Error ? error.message : String(error);
44
+ }
45
+ report.ms = Math.round(performance.now() - t0);
46
+ if (device.server.connected && !('note' in step)) {
47
+ try {
48
+ const screen = await device.screen();
49
+ report.route = screen.route;
50
+ report.screen = screen;
51
+ const events = options.events ? await drainEvents(device) : [];
52
+ if (events.length) {
53
+ report.events = events;
54
+ for (const e of events)
55
+ runEvents.push({ ...e, step: index + 1, route: screen.route });
56
+ }
57
+ if (dir) {
58
+ report.artifact = { screen: stepFile(dir, index + 1, 'json') };
59
+ writeJson(report.artifact.screen, screen);
60
+ if (options.screenshots) {
61
+ report.artifact.screenshot = stepFile(dir, index + 1, 'png');
62
+ await device.screenshot(report.artifact.screenshot);
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ // A hung or gone app is reported by the step itself.
68
+ }
69
+ }
70
+ reports.push(report);
71
+ options.onStep?.(report, index);
72
+ if (!report.ok)
73
+ break;
74
+ }
75
+ const report = {
76
+ name: flow.name,
77
+ ok: reports.every((r) => r.ok),
78
+ totalMs: Math.round(performance.now() - started),
79
+ steps: reports,
80
+ vars,
81
+ dir,
82
+ };
83
+ if (dir) {
84
+ writeEvents(dir, runEvents);
85
+ writeJson(path.join(dir, 'report.json'), { ...report, steps: report.steps.map(({ screen: _screen, ...rest }) => rest) });
86
+ }
87
+ return report;
88
+ };
89
+ const execute = async (step, flow, device, vars) => {
90
+ if ('note' in step)
91
+ return;
92
+ if ('launch' in step) {
93
+ await device.server.listen();
94
+ // A running app dials in within ~1.5 s.
95
+ const hello = device.server.hello ??
96
+ (await device.server.waitForApp(2500).then(() => device.server.hello, () => null));
97
+ if (step.launch.clean && hello)
98
+ await device.command('reset').catch(() => undefined);
99
+ // A headless host has no process to relaunch; the reset was the fresh start.
100
+ if (hello?.app?.runtime === 'headless')
101
+ return;
102
+ const appId = step.launch.appId ?? flow.appId ?? device.appId;
103
+ if (!appId)
104
+ throw new Error('launch needs an appId (step, flow, or --app)');
105
+ await device.launchApp(appId, { fresh: step.launch.fresh ?? true });
106
+ return;
107
+ }
108
+ if ('press' in step) {
109
+ await device.press(step.press);
110
+ return;
111
+ }
112
+ if ('pressIf' in step) {
113
+ const { timeoutMs, ...target } = step.pressIf;
114
+ const present = await device.waitFor({ target, timeoutMs: timeoutMs ?? 300 }).then(() => true, () => false);
115
+ if (present)
116
+ await device.press(target);
117
+ return;
118
+ }
119
+ if ('keypad' in step) {
120
+ for (const digit of step.keypad.digits) {
121
+ await device.press({ testId: step.keypad.pattern.replace('{d}', digit), mode: step.keypad.mode });
122
+ }
123
+ return;
124
+ }
125
+ if ('type' in step) {
126
+ await device.type(step.type);
127
+ return;
128
+ }
129
+ if ('swipe' in step) {
130
+ await device.swipe({ direction: step.swipe.direction });
131
+ return;
132
+ }
133
+ if ('back' in step) {
134
+ await device.back();
135
+ return;
136
+ }
137
+ if ('dismissKeyboard' in step) {
138
+ await device.server.call('dismissKeyboard');
139
+ return;
140
+ }
141
+ if ('navigate' in step) {
142
+ await device.navigate(step.navigate.name, step.navigate.params);
143
+ return;
144
+ }
145
+ if ('deepLink' in step) {
146
+ await device.openUrl(step.deepLink.url);
147
+ return;
148
+ }
149
+ if ('waitFor' in step) {
150
+ await device.waitFor(step.waitFor);
151
+ return;
152
+ }
153
+ if ('idle' in step) {
154
+ await device.idle(step.idle);
155
+ return;
156
+ }
157
+ if ('assert' in step) {
158
+ // An assertion is a wait with no patience: the state must already hold.
159
+ await device.waitFor({ ...step.assert, timeoutMs: 0 });
160
+ return;
161
+ }
162
+ if ('call' in step) {
163
+ const result = await device.command(step.call.method, step.call.params);
164
+ if (step.call.as)
165
+ vars[step.call.as] = result;
166
+ return;
167
+ }
168
+ throw new Error(`Unknown step ${JSON.stringify(step)}`);
169
+ };
@@ -0,0 +1,111 @@
1
+ import type { PressOptions, Screen, Target } from 'lynkeus-protocol';
2
+ import type { AppEvent } from '../knowledge/events.js';
3
+ /** A step's values may reference the run's variables (`{{phone}}`) or what an earlier `call` saved (`{{user.pin}}`). */
4
+ export type FlowStep =
5
+ /** `clean` runs the app's `reset` command first, if it registered one. */
6
+ {
7
+ launch: {
8
+ appId?: string;
9
+ fresh?: boolean;
10
+ clean?: boolean;
11
+ };
12
+ } | {
13
+ press: Target & PressOptions;
14
+ }
15
+ /** Taps the target only if it is on screen. */
16
+ | {
17
+ pressIf: Target & {
18
+ timeoutMs?: number;
19
+ };
20
+ } | {
21
+ type: {
22
+ text: string;
23
+ target?: Target;
24
+ clear?: boolean;
25
+ paste?: boolean;
26
+ };
27
+ } | {
28
+ swipe: {
29
+ direction: 'up' | 'down' | 'left' | 'right';
30
+ };
31
+ } | {
32
+ back: true;
33
+ } | {
34
+ navigate: {
35
+ name: string;
36
+ params?: object;
37
+ };
38
+ } | {
39
+ deepLink: {
40
+ url: string;
41
+ };
42
+ } | {
43
+ dismissKeyboard: true;
44
+ } | {
45
+ waitFor: {
46
+ route?: string;
47
+ target?: Target;
48
+ anyOf?: Target[];
49
+ gone?: boolean;
50
+ timeoutMs?: number;
51
+ };
52
+ } | {
53
+ idle: {
54
+ quietMs?: number;
55
+ timeoutMs?: number;
56
+ };
57
+ } | {
58
+ assert: {
59
+ route?: string;
60
+ target?: Target;
61
+ gone?: boolean;
62
+ };
63
+ }
64
+ /** Presses one button per digit, e.g. a code on `digit-{d}-button`. */
65
+ | {
66
+ keypad: {
67
+ digits: string;
68
+ pattern: string;
69
+ mode?: 'native' | 'js';
70
+ };
71
+ }
72
+ /** An app command registered with `qa.register`; the result is saved under `as`. */
73
+ | {
74
+ call: {
75
+ method: string;
76
+ params?: unknown;
77
+ as?: string;
78
+ };
79
+ } | {
80
+ note: string;
81
+ };
82
+ export type Flow = {
83
+ name: string;
84
+ appId?: string;
85
+ /** The variables the run must supply. */
86
+ vars?: string[];
87
+ steps: FlowStep[];
88
+ };
89
+ export type StepReport = {
90
+ step: FlowStep;
91
+ ok: boolean;
92
+ ms: number;
93
+ error?: string;
94
+ route?: string;
95
+ /** The screen after the step, when the step touched the app. */
96
+ screen?: Screen;
97
+ artifact?: {
98
+ screen: string;
99
+ screenshot?: string;
100
+ };
101
+ /** What the app told its analytics while this step ran. */
102
+ events?: AppEvent[];
103
+ };
104
+ export type FlowReport = {
105
+ name: string;
106
+ ok: boolean;
107
+ totalMs: number;
108
+ steps: StepReport[];
109
+ vars: Record<string, unknown>;
110
+ dir?: string;
111
+ };
@@ -0,0 +1 @@
1
+ export {};
package/lib/index.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ export { Device, describeScreen } from './device/driver.js';
2
+ export { bootedSimulators, pickAgentDevice } from './device/devices.js';
3
+ export type { DeviceInfo, Platform } from './device/devices.js';
4
+ export { runFlow } from './flow/runner.js';
5
+ export type { RunFlowOptions } from './flow/runner.js';
6
+ export type { Flow, FlowReport, FlowStep, StepReport } from './flow/types.js';
7
+ export { diffScreens, describeDiff } from './flow/artifacts.js';
8
+ export { smoke } from './commands/smoke.js';
9
+ export type { SmokeOptions, SmokeResult } from './commands/smoke.js';
10
+ export { crawl } from './commands/crawl.js';
11
+ export type { CrawlOptions, Transition } from './commands/crawl.js';
12
+ export { extractScreenGraph } from './knowledge/extract.js';
13
+ export { pathsTo, screensAffectedBy, vocabularyOf } from './knowledge/query.js';
14
+ export { plan, describePlan } from './knowledge/plan.js';
15
+ export type { PlanStep } from './knowledge/plan.js';
16
+ export { readRouteParams } from './knowledge/route-params.js';
17
+ export * as edges from './knowledge/edges.js';
18
+ export type { ScreenGraph } from './knowledge/types.js';
package/lib/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export { Device, describeScreen } from './device/driver.js';
2
+ export { bootedSimulators, pickAgentDevice } from './device/devices.js';
3
+ export { runFlow } from './flow/runner.js';
4
+ export { diffScreens, describeDiff } from './flow/artifacts.js';
5
+ export { smoke } from './commands/smoke.js';
6
+ export { crawl } from './commands/crawl.js';
7
+ export { extractScreenGraph } from './knowledge/extract.js';
8
+ export { pathsTo, screensAffectedBy, vocabularyOf } from './knowledge/query.js';
9
+ export { plan, describePlan } from './knowledge/plan.js';
10
+ export { readRouteParams } from './knowledge/route-params.js';
11
+ export * as edges from './knowledge/edges.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { test } from 'node:test';
6
+ import * as edges from '../edges.js';
7
+ test('a sighting is one appended line; reading merges them and compaction keeps one per edge', () => {
8
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-edges-')), 'edges.jsonl');
9
+ const store = edges.open(file);
10
+ assert.equal(store.record({ from: 'B.Main', to: 'C.Main', via: 'next' }), true);
11
+ assert.equal(store.record({ from: 'A.Main', to: 'B.Main', via: 'open' }), true);
12
+ assert.equal(store.record({ from: 'B.Main', to: 'C.Main', via: 'next' }), false);
13
+ assert.equal(fs.readFileSync(file, 'utf8').trim().split('\n').length, 3);
14
+ assert.deepEqual(edges.load(file).map((e) => [e.from, e.seen]), [
15
+ ['B.Main', 2],
16
+ ['A.Main', 1],
17
+ ]);
18
+ store.compact();
19
+ assert.deepEqual(fs
20
+ .readFileSync(file, 'utf8')
21
+ .trim()
22
+ .split('\n')
23
+ .map((l) => JSON.parse(l).from), ['A.Main', 'B.Main']);
24
+ assert.equal(edges.prune(file, new Set(['A.Main', 'B.Main'])), 1);
25
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { test } from 'node:test';
6
+ import { runFlow } from '../../flow/runner.js';
7
+ import { eventsFile, normalizeEvents, readEvents } from '../events.js';
8
+ test('an app buffer that says properties reads as props', () => {
9
+ assert.deepEqual(normalizeEvents([{ name: 'a', properties: { x: 1 } }, { name: 'b', props: { y: 2 } }, { nope: true }]), [
10
+ { name: 'a', props: { x: 1 } },
11
+ { name: 'b', props: { y: 2 } },
12
+ ]);
13
+ });
14
+ test('a run with events keeps each one with the step and route that caused it', async () => {
15
+ let buffer = [{ name: 'app_opened' }];
16
+ const routes = ['Checkout.Main', 'Checkout.Confirm'];
17
+ let step = 0;
18
+ const device = {
19
+ server: { connected: true },
20
+ press: async () => {
21
+ buffer.push({ name: step === 0 ? 'checkout_started' : 'checkout_confirmed', properties: { step } });
22
+ step += 1;
23
+ },
24
+ screen: async () => ({ route: routes[step - 1], path: [], elements: [], commit: 0, busy: false, window: { w: 1, h: 1 }, costMs: 0 }),
25
+ command: async (name, params) => {
26
+ assert.equal(name, 'events');
27
+ const out = buffer;
28
+ if (params?.clear)
29
+ buffer = [];
30
+ return out;
31
+ },
32
+ };
33
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-events-'));
34
+ const report = await runFlow({ name: 'checkout', steps: [{ press: { testId: 'start' } }, { press: { testId: 'confirm' } }] }, { device, artifactsRoot: root, events: true });
35
+ assert.deepEqual(readEvents(eventsFile(report.dir)), [
36
+ { name: 'checkout_started', props: { step: 0 }, step: 1, route: 'Checkout.Main' },
37
+ { name: 'checkout_confirmed', props: { step: 1 }, step: 2, route: 'Checkout.Confirm' },
38
+ ]);
39
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { Project } from 'ts-morph';
4
+ import { collectImports, screenDeclarations } from '../extract.js';
5
+ const file = (code) => new Project({ useInMemoryFileSystem: true }).createSourceFile('Nav.tsx', code);
6
+ test('JSX screens are found', () => {
7
+ const decls = screenDeclarations(file(`
8
+ const Nav = () => <Stack.Navigator><Stack.Screen name="A.Main" component={AMain} /></Stack.Navigator>
9
+ `));
10
+ assert.deepEqual(decls, [{ route: 'A.Main', component: 'AMain' }]);
11
+ });
12
+ test('static-config screens are found, with quoted and bare keys', () => {
13
+ const decls = screenDeclarations(file(`
14
+ const Tabs = createTabs({ screens: {
15
+ 'Settings.Notifications': { screen: SettingsNotifications, options: { lazy: false } },
16
+ Settings: { screen: SettingsMain },
17
+ } })
18
+ `));
19
+ assert.deepEqual(decls, [
20
+ { route: 'Settings.Notifications', component: 'SettingsNotifications' },
21
+ { route: 'Settings', component: 'SettingsMain' },
22
+ ]);
23
+ });
24
+ test('a static entry whose screen is a nested navigator call is not a screen', () => {
25
+ const decls = screenDeclarations(file(`
26
+ const Root = createStack({ screens: { 'Root.Tabs': { screen: buildRootTabs({ hideFirst: false }) } } })
27
+ `));
28
+ assert.deepEqual(decls, []);
29
+ });
30
+ test('an object with a screen key inside a spread still counts', () => {
31
+ const decls = screenDeclarations(file(`
32
+ const tabs = { ...(hide ? {} : { 'Settings.Main': { screen: SettingsMain } }) }
33
+ `));
34
+ assert.deepEqual(decls, [{ route: 'Settings.Main', component: 'SettingsMain' }]);
35
+ });
36
+ test('navigate params that name a screen are not declarations', () => {
37
+ const decls = screenDeclarations(file(`
38
+ navigation.navigate('Root.Tabs', { screen: 'Settings.Main', params: { screen: 'Settings.Main' } })
39
+ const target = { name: 'Root.Tabs', params: { screen: 'Settings.Main' } }
40
+ `));
41
+ assert.deepEqual(decls, []);
42
+ });
43
+ const app = (files) => {
44
+ const project = new Project({ useInMemoryFileSystem: true });
45
+ for (const [name, code] of Object.entries(files))
46
+ project.createSourceFile(`/app/${name}`, code);
47
+ return (name) => collectImports(project.getSourceFileOrThrow(`/app/${name}`), '/app');
48
+ };
49
+ test('a name imported through a barrel points at the file that defines it', () => {
50
+ const imports = app({
51
+ 'hooks/useThing.ts': 'const useThing = () => 1\nexport { useThing }',
52
+ 'hooks/useOther.ts': 'export const useOther = () => 2',
53
+ 'hooks/index.ts': "export * from './useThing'\nexport { useOther as useRenamed } from './useOther'",
54
+ 'Screen.tsx': "import { useThing } from './hooks'",
55
+ 'Other.tsx': "import { useRenamed } from './hooks'",
56
+ });
57
+ assert.deepEqual(imports('Screen.tsx'), { imports: ['hooks/useThing.ts'], barrels: ['hooks/index.ts'] });
58
+ assert.deepEqual(imports('Other.tsx'), { imports: ['hooks/useOther.ts'], barrels: ['hooks/index.ts'] });
59
+ });
60
+ test('what cannot be traced to one file stays on the module it was imported from', () => {
61
+ const imports = app({
62
+ 'hooks/useThing.ts': 'export const useThing = () => 1',
63
+ 'hooks/index.ts': "export * from './useThing'\nexport const here = 1",
64
+ 'Whole.tsx': "import * as hooks from './hooks'",
65
+ 'Local.tsx': "import { here, missing } from './hooks'",
66
+ });
67
+ assert.deepEqual(imports('Whole.tsx'), { imports: ['hooks/index.ts'], barrels: [] });
68
+ assert.deepEqual(imports('Local.tsx'), { imports: ['hooks/index.ts'], barrels: [] });
69
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { test } from 'node:test';
6
+ import * as frontier from '../frontier.js';
7
+ const tmpFile = () => path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-frontier-')), 'frontier.jsonl');
8
+ test('a place owes what it has not tried', () => {
9
+ const file = tmpFile();
10
+ const memory = frontier.open(file);
11
+ memory.record('Settings.Main', { seen: ['share-button', 'account-button', 'profile-button'], visited: true });
12
+ memory.record('Settings.Main', { tried: ['share-button'] });
13
+ const [row] = frontier.load(file);
14
+ assert.deepEqual(row?.tried, ['share-button']);
15
+ assert.deepEqual(row?.left, ['account-button', 'profile-button']);
16
+ assert.deepEqual(memory.tried('Settings.Main'), ['share-button']);
17
+ });
18
+ test('a place with nothing left is not offered again', () => {
19
+ const memory = frontier.open(tmpFile());
20
+ memory.record('Settings.Main', { seen: ['a'], visited: true });
21
+ memory.record('Profile.Main', { seen: ['b', 'c'], visited: true });
22
+ memory.record('Settings.Main', { tried: ['a'] });
23
+ assert.deepEqual(memory.unfinished(), ['Profile.Main']);
24
+ });
25
+ test('a place only ever seen from outside owes a first visit, before any remainder', () => {
26
+ const memory = frontier.open(tmpFile());
27
+ memory.record('Settings.Main', { seen: ['a', 'b', 'c'], visited: true });
28
+ memory.record('Settings.Main', { tried: ['a'] });
29
+ memory.record('Profile.Edit', {});
30
+ assert.deepEqual(memory.unfinished(), ['Profile.Edit', 'Settings.Main']);
31
+ });
32
+ test('a layer is a place of its own', () => {
33
+ const memory = frontier.open(tmpFile());
34
+ memory.record('Settings.Main#account-button', { seen: ['nested-button'], visited: true });
35
+ memory.record('Settings.Main', { seen: ['account-button'], tried: ['account-button'], visited: true });
36
+ assert.deepEqual(memory.unfinished(), ['Settings.Main#account-button']);
37
+ });
38
+ test('a run appends as it goes and compacts to one sorted line per place', () => {
39
+ const file = tmpFile();
40
+ const memory = frontier.open(file);
41
+ memory.record('Settings.Main', { seen: ['a', 'b'], visited: true });
42
+ memory.record('Settings.Main', { tried: ['a'] });
43
+ memory.record('Account.Main', { seen: ['c'] });
44
+ assert.equal(fs.readFileSync(file, 'utf8').trim().split('\n').length, 3);
45
+ assert.deepEqual(frontier.open(file).tried('Settings.Main'), ['a']);
46
+ memory.compact();
47
+ const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
48
+ assert.deepEqual(lines.map((l) => JSON.parse(l).node), ['Account.Main', 'Settings.Main']);
49
+ });
50
+ test('a deleted screen takes its unfinished controls with it', () => {
51
+ const file = tmpFile();
52
+ const memory = frontier.open(file);
53
+ memory.record('Settings.Main', { seen: ['a'], visited: true });
54
+ memory.record('Gone.Screen#sheet', { seen: ['b'], visited: true });
55
+ assert.equal(frontier.prune(file, new Set(['Settings.Main'])), 1);
56
+ assert.deepEqual(frontier.load(file).map((r) => r.node), ['Settings.Main']);
57
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { checkFunnel, diffEvents, funnelCoverage } from '../funnels.js';
4
+ const signup = {
5
+ name: 'signup',
6
+ steps: [
7
+ { event: 'signup_started', props: { source: 'string', step: 'number' } },
8
+ { event: 'signup_completed', props: { method: 'email' } },
9
+ ],
10
+ };
11
+ test('a run that produced every step in order, with the right shapes, passes', () => {
12
+ const result = checkFunnel(signup, [
13
+ { name: 'app_opened', step: 1 },
14
+ { name: 'signup_started', props: { source: 'ad', step: 2 }, step: 2 },
15
+ { name: 'signup_completed', props: { method: 'email' }, step: 4 },
16
+ ]);
17
+ assert.equal(result.ok, true);
18
+ assert.deepEqual(result.steps.map((s) => s.at), [2, 4]);
19
+ });
20
+ test('a missing step, a step out of order and a property of the wrong type each say so', () => {
21
+ assert.equal(checkFunnel(signup, [{ name: 'signup_started', props: { source: 'ad', step: 2 } }]).steps[1]?.problem, 'never happened');
22
+ assert.equal(checkFunnel(signup, [
23
+ { name: 'signup_completed', props: { method: 'email' } },
24
+ { name: 'signup_started', props: { source: 'ad', step: 2 } },
25
+ ]).steps[1]?.problem, 'happened, but before the step it should follow');
26
+ assert.equal(checkFunnel(signup, [
27
+ { name: 'signup_started', props: { source: 'ad', step: '2' } },
28
+ { name: 'signup_completed', props: { method: 'phone' } },
29
+ ])
30
+ .steps.map((s) => s.problem)
31
+ .join(' | '), 'step is string, the funnel needs number | method is "phone", the funnel filters on "email"');
32
+ });
33
+ test('between two builds: an event that appeared, one that was lost, one whose properties changed', () => {
34
+ const diff = diffEvents([{ name: 'a', props: { step: 1 } }, { name: 'gone' }], [{ name: 'a', props: { step: '1', ref: 'x' } }, { name: 'new' }]);
35
+ assert.deepEqual(diff, { added: ['new'], lost: ['gone'], changed: [{ event: 'a', changes: ['+ref', 'step number→string'] }] });
36
+ });
37
+ test('coverage names the funnel steps no run produces', () => {
38
+ const coverage = funnelCoverage([signup], [{ run: 'signup/1', events: [{ name: 'signup_started' }] }]);
39
+ assert.deepEqual(coverage[0]?.steps.map((s) => s.runs.length), [1, 0]);
40
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { lintScreen } from '../lint.js';
4
+ const screen = (elements) => ({
5
+ path: ['Root'],
6
+ commit: 1,
7
+ busy: false,
8
+ window: { w: 390, h: 844 },
9
+ costMs: 1,
10
+ elements: elements.map((e, i) => ({ i, kind: 'view', frame: { x: 0, y: 0, w: 100, h: 50 }, enabled: true, depth: 0, ...e })),
11
+ });
12
+ test('an unlabeled button is flagged', () => {
13
+ const f = lintScreen(screen([{ kind: 'button' }]));
14
+ assert.equal(f.filter((x) => x.rule === 'unlabeled-control').length, 1);
15
+ });
16
+ test('a labeled button is not', () => {
17
+ const f = lintScreen(screen([{ kind: 'button', testId: 'ok' }]));
18
+ assert.equal(f.filter((x) => x.rule === 'unlabeled-control').length, 0);
19
+ });
20
+ test('an icon-only small target warns, a labeled one (a tab item) is info', () => {
21
+ const f = lintScreen(screen([
22
+ { kind: 'button', testId: 'icon', frame: { x: 0, y: 0, w: 24, h: 24 } },
23
+ { kind: 'button', testId: 'tab', text: 'HOME', frame: { x: 0, y: 0, w: 26, h: 16 } },
24
+ ]));
25
+ const small = f.filter((x) => x.rule === 'small-target');
26
+ assert.equal(small.find((x) => x.message.includes('icon'))?.severity, 'warn');
27
+ assert.equal(small.find((x) => x.message.includes('tab'))?.severity, 'info');
28
+ });
29
+ test('an untranslated i18n key is flagged, real copy is not', () => {
30
+ const f = lintScreen(screen([
31
+ { kind: 'text', text: 'profile.settings.title' },
32
+ { kind: 'text', text: 'Settings' },
33
+ ]));
34
+ assert.equal(f.filter((x) => x.rule === 'untranslated').length, 1);
35
+ });
36
+ test('the theme-[object Object] duplicate testId is caught', () => {
37
+ const f = lintScreen(screen([
38
+ { kind: 'button', testId: 'theme-[object Object]-radio', frame: { x: 0, y: 0, w: 24, h: 24 } },
39
+ { kind: 'button', testId: 'theme-[object Object]-radio', frame: { x: 0, y: 100, w: 24, h: 24 } },
40
+ ]));
41
+ assert.equal(f.filter((x) => x.rule === 'duplicate-testid').length, 1);
42
+ });
43
+ test('a covered interactive element is info, a covered view is nothing', () => {
44
+ const f = lintScreen(screen([
45
+ { kind: 'button', testId: 'under', covered: true },
46
+ { kind: 'view', covered: true },
47
+ ]));
48
+ assert.equal(f.filter((x) => x.rule === 'covered-control').length, 1);
49
+ });
50
+ test('an element drawn past the window is flagged', () => {
51
+ const f = lintScreen(screen([{ kind: 'text', text: 'wide', frame: { x: 300, y: 0, w: 200, h: 20 } }]));
52
+ assert.equal(f.filter((x) => x.rule === 'offscreen').length, 1);
53
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { summarize, visitsFrom } from '../perf.js';
4
+ const ev = (partial, seq) => ({ seq, ...partial });
5
+ const trace = [
6
+ ev({ kind: 'route', route: 'First.Main', path: ['First.Main'], t: 1000 }, 1),
7
+ ev({ kind: 'request', method: 'GET', url: 'http://x/api/a', status: 200, ms: 120, t: 1130 }, 2),
8
+ ev({ kind: 'request', method: 'GET', url: 'http://x/api/b?limit=100', status: 500, ms: 900, t: 1920 }, 3),
9
+ // Polling long after arrival is not part of what the screen cost to open.
10
+ ev({ kind: 'request', method: 'GET', url: 'http://x/api/a', status: 200, ms: 50, t: 4000 }, 8),
11
+ ev({ kind: 'stall', renders: 50, windowMs: 100, t: 1500 }, 4),
12
+ ev({ kind: 'route', route: 'Second.Main', path: ['Second.Main'], t: 5000 }, 5),
13
+ ev({ kind: 'request', method: 'GET', url: 'http://x/api/c', status: 200, ms: 40, t: 5045 }, 6),
14
+ ev({ kind: 'command', method: 'press', ms: 5, t: 5100 }, 7),
15
+ ];
16
+ test('a visit counts its requests, errors, stalls and when it went quiet', () => {
17
+ const [home, pay] = visitsFrom(trace);
18
+ assert.equal(home.route, 'First.Main');
19
+ assert.equal(home.requests, 3);
20
+ assert.equal(home.errors, 1);
21
+ assert.equal(home.stalls, 1);
22
+ assert.equal(home.quietAfterMs, 920); // the 900 ms request ended at +920; the poll at +3950 came after a quiet gap
23
+ assert.deepEqual(home.slowest, { method: 'GET', path: '/api/b?limit=100', ms: 900 });
24
+ assert.equal(home.ms, 4000);
25
+ assert.equal(pay.requests, 1);
26
+ });
27
+ test('the summary averages per route and puts the slowest screen first', () => {
28
+ const s = summarize(visitsFrom(trace));
29
+ assert.deepEqual(s.map((r) => r.route), ['First.Main', 'Second.Main']);
30
+ assert.equal(s[0].quietAfterMs, 920);
31
+ });
32
+ test('events before any route are ignored', () => {
33
+ assert.deepEqual(visitsFrom([ev({ kind: 'request', method: 'GET', url: 'http://x/a', ms: 1, t: 1 }, 1)]), []);
34
+ });
@@ -0,0 +1 @@
1
+ export {};