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,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import type { ObservedEdge } from '../edges.js';
5
+ import { describePlan, plan } from '../plan.js';
6
+ import type { ScreenGraph } from '../types.js';
7
+
8
+ const graph = (routes: string[]): ScreenGraph => ({
9
+ app: 'test',
10
+ root: '/tmp',
11
+ extractedAt: '2026-01-01',
12
+ screens: Object.fromEntries(routes.map((route) => [route, { route, component: route, file: null, declaredIn: 'nav.tsx' }])),
13
+ files: {},
14
+ warnings: [],
15
+ });
16
+
17
+ const edge = (e: Partial<ObservedEdge> & Pick<ObservedEdge, 'from' | 'to' | 'via'>): ObservedEdge => ({ seen: 1, last: '2026-01-01', ...e });
18
+
19
+ test('a sheet is a place the directions can reach', () => {
20
+ const steps = plan(graph(['Settings.Main', 'Account.Delete']), 'Settings.Main', 'Account.Delete', [
21
+ edge({ from: 'Settings.Main', to: 'Settings.Main', via: 'account-button', toLayer: 'account-button' }),
22
+ edge({ from: 'Settings.Main', to: 'Account.Delete', via: 'delete-account-button', fromLayer: 'account-button' }),
23
+ ]);
24
+
25
+ assert.deepEqual(
26
+ steps?.map((s) => [s.from, s.press, s.to]),
27
+ [
28
+ ['Settings.Main', 'account-button', 'Settings.Main#account-button'],
29
+ ['Settings.Main#account-button', 'delete-account-button', 'Account.Delete'],
30
+ ],
31
+ );
32
+ assert.match(describePlan(steps!), /opens a layer/);
33
+ });
34
+
35
+ test('what a screen offers does not apply while a sheet covers it', () => {
36
+ const steps = plan(graph(['Settings.Main', 'Profile.Edit']), 'Settings.Main#account-button', 'Profile.Edit', [
37
+ edge({ from: 'Settings.Main', to: 'Profile.Edit', via: 'profile-button' }),
38
+ ]);
39
+
40
+ assert.equal(steps, null);
41
+ });
42
+
43
+ test('asking for a route accepts arriving with something open over it', () => {
44
+ const steps = plan(graph(['Settings.Main', 'Checkout.Main']), 'Settings.Main', 'Checkout.Main', [
45
+ edge({ from: 'Settings.Main', to: 'Checkout.Main', via: 'send-button', toLayer: 'send-button' }),
46
+ ]);
47
+
48
+ assert.deepEqual(
49
+ steps?.map((s) => s.to),
50
+ ['Checkout.Main#send-button'],
51
+ );
52
+ });
@@ -0,0 +1,74 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import { screensAffectedBy } from '../query.js';
5
+ import type { ScreenGraph } from '../types.js';
6
+
7
+ const graph = {
8
+ app: 'test',
9
+ root: 'Root',
10
+ extractedAt: '',
11
+ warnings: [],
12
+ screens: {
13
+ 'A.Main': { route: 'A.Main', component: 'A', file: 'app/a.tsx', declaredIn: 'nav.tsx' },
14
+ 'B.Main': { route: 'B.Main', component: 'B', file: 'app/b.tsx', declaredIn: 'nav.tsx' },
15
+ },
16
+ files: {
17
+ 'app/a.tsx': { imports: ['app/shared.tsx'] },
18
+ 'app/b.tsx': { imports: [] },
19
+ 'app/shared.tsx': { imports: [] },
20
+ },
21
+ } as unknown as ScreenGraph;
22
+
23
+ test('a changed screen file is affected at distance 0', () => {
24
+ const r = screensAffectedBy(graph, ['app/a.tsx']);
25
+ assert.deepEqual(
26
+ r.map((x) => [x.route, x.distance]),
27
+ [['A.Main', 0]],
28
+ );
29
+ });
30
+ test('a shared file reaches its importer at distance 1, naming the changed file', () => {
31
+ const r = screensAffectedBy(graph, ['app/shared.tsx']);
32
+ assert.deepEqual(
33
+ r.map((x) => [x.route, x.distance, x.via]),
34
+ [['A.Main', 1, 'app/shared.tsx']],
35
+ );
36
+ });
37
+ test('results are nearest first', () => {
38
+ const g = {
39
+ ...graph,
40
+ screens: { ...graph.screens, 'C.Main': { route: 'C.Main', component: 'C', file: 'app/c.tsx', declaredIn: 'nav.tsx' } },
41
+ files: { ...graph.files, 'app/c.tsx': { imports: ['app/a.tsx'] } },
42
+ } as unknown as ScreenGraph;
43
+ const r = screensAffectedBy(g, ['app/shared.tsx']);
44
+ assert.deepEqual(
45
+ r.map((x) => [x.route, x.distance]),
46
+ [
47
+ ['A.Main', 1],
48
+ ['C.Main', 2],
49
+ ],
50
+ );
51
+ });
52
+ test('an unrelated change affects nothing', () => {
53
+ assert.deepEqual(screensAffectedBy(graph, ['app/unrelated.tsx']), []);
54
+ });
55
+ test('a barrel reaches who imports through it only when the barrel itself changed', () => {
56
+ const g = {
57
+ ...graph,
58
+ files: {
59
+ 'app/a.tsx': { imports: ['app/hooks/useThing.ts'], barrels: ['app/hooks/index.ts'] },
60
+ 'app/b.tsx': { imports: ['app/hooks/useOther.ts'], barrels: ['app/hooks/index.ts'] },
61
+ 'app/hooks/index.ts': { imports: ['app/hooks/useThing.ts', 'app/hooks/useOther.ts'] },
62
+ 'app/hooks/useThing.ts': { imports: [] },
63
+ 'app/hooks/useOther.ts': { imports: [] },
64
+ },
65
+ } as unknown as ScreenGraph;
66
+ assert.deepEqual(
67
+ screensAffectedBy(g, ['app/hooks/useThing.ts']).map((x) => x.route),
68
+ ['A.Main'],
69
+ );
70
+ assert.deepEqual(
71
+ screensAffectedBy(g, ['app/hooks/index.ts']).map((x) => x.route),
72
+ ['A.Main', 'B.Main'],
73
+ );
74
+ });
@@ -0,0 +1,37 @@
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
+
7
+ import type { Screen } from 'lynkeus-protocol';
8
+
9
+ import { diffGeometry, record } from '../screens.js';
10
+
11
+ const f = (x: number, y: number, w: number, h: number) => ({ x, y, w, h });
12
+
13
+ test('drift within the tolerance is not a change', () => {
14
+ assert.deepEqual(diffGeometry({ a: f(10, 10, 100, 40) }, { a: f(12, 8, 102, 41) }), []);
15
+ });
16
+ test('a move past the tolerance is reported with both positions', () => {
17
+ assert.deepEqual(diffGeometry({ a: f(10, 10, 100, 40) }, { a: f(10, 60, 100, 40) }), ['#a moved (10,10)→(10,60)']);
18
+ });
19
+ test('a resize is reported', () => {
20
+ assert.deepEqual(diffGeometry({ a: f(0, 0, 100, 40) }, { a: f(0, 0, 100, 80) }), ['#a resized 100×40→100×80']);
21
+ });
22
+ test('an element that collapsed to zero is named as such', () => {
23
+ assert.deepEqual(diffGeometry({ a: f(0, 0, 100, 40) }, { a: f(0, 0, 100, 0) }), ['#a collapsed to 100×0 (was 100×40)']);
24
+ });
25
+ test('elements only on one side are not geometry changes', () => {
26
+ assert.deepEqual(diffGeometry({ a: f(0, 0, 1, 1) }, { b: f(0, 0, 1, 1) }), []);
27
+ });
28
+
29
+ test('a recorded screen keeps what is presented, not what another tab or a covered screen holds', () => {
30
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-screens-'));
31
+ const el = (i: number, testId: string, hidden?: string) => ({ i, kind: 'button', frame: f(0, i * 50, 100, 40), enabled: true, depth: 0, testId, hidden });
32
+ const screen = (elements: object[]) =>
33
+ ({ route: 'Settings.Main', path: ['Settings.Main'], commit: 1, busy: false, window: { w: 390, h: 844 }, costMs: 1, elements }) as unknown as Screen;
34
+ record(dir, screen([el(0, 'save'), el(1, 'other-tab', 'a11y'), el(2, 'slide', 'inert')]));
35
+ const change = record(dir, screen([el(0, 'save'), el(2, 'slide', 'inert')]));
36
+ assert.equal(change, null);
37
+ });
@@ -0,0 +1,40 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import { explain } from '../why.js';
5
+ import type { Element, RequestRecord, Screen, TraceEvent } from 'lynkeus-protocol';
6
+
7
+ const el = (p: Partial<Element>, i: number): Element =>
8
+ ({ i, kind: 'button', frame: { x: 0, y: 0, w: 10, h: 10 }, enabled: true, depth: 1, tag: i + 1, ...p }) as Element;
9
+ const screen = (elements: Element[], route?: string): Screen => ({ route, path: route ? [route] : [], elements, commit: 5, costMs: 1 }) as unknown as Screen;
10
+ const req = (status: number | undefined, url: string, error?: string): RequestRecord => ({ id: 1, method: 'GET', url, status, startedAt: 0, ms: 10, error });
11
+ const rules = (f: ReturnType<typeof explain>) => f.map((x) => x.rule);
12
+
13
+ test('a dead navigator and a gone session are named', () => {
14
+ assert.deepEqual(rules(explain({ screen: screen([]), requests: [], inFlight: 0 })), ['dead-navigator']);
15
+ assert.ok(
16
+ rules(explain({ screen: screen([el({}, 0)], 'Home'), requests: [req(401, 'http://x/a'), req(401, 'http://x/b')], inFlight: 0 })).includes('session-gone'),
17
+ );
18
+ });
19
+
20
+ test('a covered target, a near text and a capped list', () => {
21
+ const s = screen([el({ testId: 'submit', covered: true }, 0), el({ kind: 'text', text: 'Café Ünïcode wörks here' }, 1)], 'Home');
22
+ assert.ok(rules(explain({ screen: s, requests: [], inFlight: 0, target: { testId: 'submit' } })).includes('covered'));
23
+ assert.ok(rules(explain({ screen: s, requests: [], inFlight: 0, target: { text: 'Cafe Unicode works here' } })).includes('near-match'));
24
+ assert.ok(rules(explain({ screen: s, requests: [], inFlight: 0, target: { text: 'cafe unicode works here' } })).includes('near-match'));
25
+ assert.ok(!rules(explain({ screen: s, requests: [], inFlight: 0, target: { text: 'A completely different sentence' } })).includes('near-match'));
26
+ const list = screen(
27
+ Array.from({ length: 12 }, (_, i) => el({ testId: `row-${i}-item`, text: `Item${i}` }, i)),
28
+ 'Search',
29
+ );
30
+ assert.ok(rules(explain({ screen: list, requests: [req(200, 'http://x/search')], inFlight: 0, target: { text: 'itemabc' } })).includes('capped-list'));
31
+ });
32
+
33
+ test('backend and loading conditions', () => {
34
+ const s = screen([el({}, 0)], 'Home');
35
+ assert.ok(rules(explain({ screen: s, requests: [req(500, 'http://x/api/a')], inFlight: 0 })).includes('backend-error'));
36
+ assert.ok(rules(explain({ screen: s, requests: [req(200, 'http://x/a')], inFlight: 2, target: { testId: 'x' } })).includes('in-flight'));
37
+ const stall: TraceEvent = { seq: 1, t: 1, kind: 'stall', renders: 2000, windowMs: 3000, component: 'Feed' };
38
+ assert.ok(rules(explain({ screen: s, requests: [], inFlight: 0, trace: [stall] })).includes('render-loop'));
39
+ assert.deepEqual(rules(explain({ screen: s, requests: [req(200, 'http://x/a')], inFlight: 0 })), []);
40
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Navigations a run has actually walked, as `edges.jsonl`: one sorted line per
3
+ * distinct edge so concurrent edits merge in git, and a counter instead of a
4
+ * row per sighting so the file converges instead of growing.
5
+ *
6
+ * A run appends one line per sighting and compacts once at the end; reading
7
+ * merges duplicate lines, so a run that is killed halfway loses nothing.
8
+ */
9
+ import { appendJsonl, readJsonl, today, writeJsonl } from '../files.js';
10
+
11
+ export type ObservedEdge = {
12
+ from: string;
13
+ to: string;
14
+ /** A testID, or `text:<label>` when the control had none. */
15
+ via: string;
16
+ /** The layer open over `from` when the press happened, named after the control that opened it. */
17
+ fromLayer?: string;
18
+ /** The layer left open over `to`, named the same way. */
19
+ toLayer?: string;
20
+ seen: number;
21
+ /** A date, not a timestamp, so a run does not churn every line. */
22
+ last: string;
23
+ };
24
+
25
+ export type EdgeObservation = Pick<ObservedEdge, 'from' | 'to' | 'via' | 'fromLayer' | 'toLayer'>;
26
+
27
+ const nodeId = (route: string, layer?: string) => (layer ? `${route}#${layer}` : route);
28
+
29
+ export const splitNode = (node: string): { route: string; layer?: string } => {
30
+ const at = node.indexOf('#');
31
+ return at === -1 ? { route: node } : { route: node.slice(0, at), layer: node.slice(at + 1) };
32
+ };
33
+
34
+ export const edgeFrom = (edge: EdgeObservation) => nodeId(edge.from, edge.fromLayer);
35
+ export const edgeTo = (edge: EdgeObservation) => nodeId(edge.to, edge.toLayer);
36
+
37
+ const key = (edge: EdgeObservation) => `${edgeFrom(edge)} ${edge.via} ${edgeTo(edge)}`;
38
+
39
+ const merge = (into: ObservedEdge, row: ObservedEdge) => {
40
+ into.seen += row.seen;
41
+ if (row.last > into.last) into.last = row.last;
42
+ };
43
+
44
+ const merged = (rows: ObservedEdge[]): Map<string, ObservedEdge> => {
45
+ const byKey = new Map<string, ObservedEdge>();
46
+ for (const row of rows) {
47
+ const found = byKey.get(key(row));
48
+ if (found) merge(found, row);
49
+ else byKey.set(key(row), { ...row });
50
+ }
51
+ return byKey;
52
+ };
53
+
54
+ const save = (file: string, edges: ObservedEdge[]) =>
55
+ writeJsonl(
56
+ file,
57
+ [...edges].sort((a, b) => key(a).localeCompare(key(b))),
58
+ );
59
+
60
+ export const load = (file: string): ObservedEdge[] => [...merged(readJsonl<ObservedEdge>(file)).values()];
61
+
62
+ /** The edges in a file, kept in memory while a run adds to them. */
63
+ export const open = (file: string) => {
64
+ const known = merged(readJsonl<ObservedEdge>(file));
65
+ let appended = false;
66
+ return {
67
+ all: (): ObservedEdge[] => [...known.values()],
68
+ /** True when the edge is new. */
69
+ record: (observation: EdgeObservation): boolean => {
70
+ const row: ObservedEdge = { ...observation, seen: 1, last: today() };
71
+ appendJsonl(file, row);
72
+ appended = true;
73
+ const found = known.get(key(row));
74
+ if (found) merge(found, row);
75
+ else known.set(key(row), { ...row });
76
+ return !found;
77
+ },
78
+ compact: () => {
79
+ if (appended) save(file, load(file));
80
+ },
81
+ };
82
+ };
83
+
84
+ /** Drops the edges of routes that no longer exist, so the store is collected against the app rather than a clock. */
85
+ export const prune = (file: string, routes: Set<string>): number => {
86
+ const before = load(file);
87
+ if (before.length === 0) return 0;
88
+ const after = before.filter((e) => routes.has(e.from) && routes.has(e.to));
89
+ save(file, after);
90
+ return before.length - after.length;
91
+ };
@@ -0,0 +1,73 @@
1
+ /** The usual cause and fix of a headless failure, found in jest's output so nobody reads a 300-line stack for it. */
2
+ export type Explanation = { cause: string; fix: string; match: string };
3
+
4
+ type Rule = { re: RegExp; cause: (m: RegExpMatchArray) => string; fix: (m: RegExpMatchArray) => string };
5
+
6
+ const RULES: Rule[] = [
7
+ {
8
+ re: /TurboModuleRegistry\.getEnforcing\(\.\.\.\): '([^']+)' could not be found/,
9
+ cause: (m) => `the native module ${m[1]} does not exist under jest`,
10
+ fix: (m) =>
11
+ `mock the package that owns ${m[1]} in the headless entry: jest.mock('<package>', () => ({ ... })). \`lynkeus headless doctor\` lists where it is referenced.`,
12
+ },
13
+ {
14
+ re: /Invariant Violation: (?:requireNativeComponent: )?"?([\w.]+)"? was not found in the UIManager/,
15
+ cause: (m) => `the native view ${m[1]} has no jest counterpart`,
16
+ fix: (m) => `mock the library that renders ${m[1]} with a plain View (see the vision-camera and webview stubs lynkeus ships for the shape).`,
17
+ },
18
+ {
19
+ re: /Cannot find module '([^']+)' from '([^']+)'/,
20
+ cause: (m) => `${m[2]} imports ${m[1]}, which jest cannot resolve`,
21
+ fix: (m) => `if ${m[1]} is a path alias, add it to jest moduleNameMapper; if it is an optional native package, jest.mock it in the headless entry.`,
22
+ },
23
+ {
24
+ re: /lynkeus headless only drives a local backend; API_URL is "([^"]*)"/,
25
+ cause: (m) => `API_URL is "${m[1] || '(empty)'}", not a local backend`,
26
+ fix: () =>
27
+ `export API_URL=http://127.0.0.1:<port>/ before \`lynkeus headless start\`; if the app inlines env at build time, clear its jest transform cache too.`,
28
+ },
29
+ {
30
+ re: /(\w+)\.(\w+) is not a function/,
31
+ cause: (m) => `${m[1]}.${m[2]} is missing — usually a mock that does not implement a method the app calls`,
32
+ fix: (m) => `extend the mock for ${m[1]} with ${m[2]}; the lynkeus defaults can be overridden per key in installDefaultMocks({ overrides }).`,
33
+ },
34
+ {
35
+ re: /Cannot read propert(?:y|ies) of undefined \(reading '(\w+)'\)/,
36
+ cause: (m) => `something the app expected to exist is undefined (reading '${m[1]}')`,
37
+ fix: () =>
38
+ `look one frame up the stack for the module: a mock returning {} instead of the shape the app reads, or a store not initialised because a service failed earlier.`,
39
+ },
40
+ {
41
+ re: /Jest did not exit one second after the test run has completed/,
42
+ cause: () => `the host finished but something kept the event loop alive`,
43
+ fix: () => `harmless for a host that is meant to stay up; when it is not, an open socket or timer from a mock is the usual culprit.`,
44
+ },
45
+ {
46
+ re: /ECONNREFUSED (\d+\.\d+\.\d+\.\d+):(\d+)/,
47
+ cause: (m) => `nothing is listening on ${m[1]}:${m[2]}`,
48
+ fix: () => `start the backend (or the lynkeus driver, if this is the agent's socket) and check the port the entry dials.`,
49
+ },
50
+ {
51
+ re: /Test environment jest-environment-\w+ cannot be found|Unexpected token 'export'|SyntaxError: Cannot use import statement/,
52
+ cause: () => `jest is not transforming a dependency (ESM in node_modules)`,
53
+ fix: () =>
54
+ `add the package to transformIgnorePatterns' allow-list in the app's jest config, the same way react-native itself is listed (lynkeus-agent and lynkeus-headless need 'lynkeus-[^/]+' there).`,
55
+ },
56
+ {
57
+ re: /ReferenceError: (\w+) is not defined/,
58
+ cause: (m) => `${m[1]} is a global the runtime does not provide under jest`,
59
+ fix: (m) => `define ${m[1]} in the headless entry before requiring the app (globalThis.${m[1]} = ...), or mock the module that expects it.`,
60
+ },
61
+ ];
62
+
63
+ export const explainError = (text: string): Explanation[] => {
64
+ const out: Explanation[] = [];
65
+ for (const rule of RULES) {
66
+ const m = text.match(rule.re);
67
+ if (m) out.push({ match: m[0], cause: rule.cause(m), fix: rule.fix(m) });
68
+ }
69
+ return out;
70
+ };
71
+
72
+ export const describeExplanations = (list: Explanation[]): string =>
73
+ list.length === 0 ? '' : ['', 'lynkeus: what this looks like', ...list.map((e) => ` ${e.match}\n cause ${e.cause}\n fix ${e.fix}`)].join('\n');
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { readJsonl } from '../files.js';
5
+
6
+ export type AppEvent = { name: string; props?: Record<string, unknown> };
7
+ export type RunEvent = AppEvent & { step: number; route?: string };
8
+
9
+ type Drainable = { command<T = unknown>(name: string, params?: unknown): Promise<T> };
10
+
11
+ /** An app's own buffer may say `properties`; lynkeus says `props`. */
12
+ export const normalizeEvents = (raw: unknown): AppEvent[] =>
13
+ (Array.isArray(raw) ? raw : []).flatMap((e) => {
14
+ const r = e as { name?: unknown; props?: unknown; properties?: unknown };
15
+ if (typeof r?.name !== 'string') return [];
16
+ const props = (r.props ?? r.properties) as Record<string, unknown> | undefined;
17
+ return [{ name: r.name, ...(props && typeof props === 'object' ? { props } : {}) }];
18
+ });
19
+
20
+ export const drainEvents = async (device: Drainable): Promise<AppEvent[]> => {
21
+ try {
22
+ return normalizeEvents(await device.command('events', { clear: true }));
23
+ } catch {
24
+ return [];
25
+ }
26
+ };
27
+
28
+ export const eventsFile = (runDir: string) => path.join(runDir, 'events.jsonl');
29
+
30
+ export const writeEvents = (runDir: string, events: RunEvent[]): void => {
31
+ if (events.length === 0) return;
32
+ fs.writeFileSync(eventsFile(runDir), `${events.map((e) => JSON.stringify(e)).join('\n')}\n`);
33
+ };
34
+
35
+ export const readEvents = (file: string): RunEvent[] => readJsonl<RunEvent>(file);
36
+
37
+ /** Every run under `runs/` that recorded events, newest first. */
38
+ export const eventsFiles = (runsRoot: string): string[] => {
39
+ const found: { file: string; mtime: number }[] = [];
40
+ const visit = (dir: string, depth: number) => {
41
+ if (depth > 3 || !fs.existsSync(dir)) return;
42
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
43
+ const full = path.join(dir, entry.name);
44
+ if (entry.isDirectory()) visit(full, depth + 1);
45
+ else if (entry.name === 'events.jsonl') found.push({ file: full, mtime: fs.statSync(full).mtimeMs });
46
+ }
47
+ };
48
+ visit(runsRoot, 0);
49
+ return found.sort((a, b) => b.mtime - a.mtime).map((f) => f.file);
50
+ };
51
+
52
+ /** A run folder, `last`, or an events.jsonl itself. */
53
+ export const resolveEventsFile = (runsRoot: string, ref: string): string | undefined => {
54
+ if (ref === 'last') return eventsFiles(runsRoot)[0];
55
+ const full = path.resolve(ref);
56
+ return full.endsWith('.jsonl') ? full : eventsFile(full);
57
+ };
58
+
59
+ export const describeEvents = (events: RunEvent[]): string =>
60
+ events.map((e) => `${String(e.step).padStart(2)} ${e.route ?? '?'} ${e.name}${e.props ? ` ${JSON.stringify(e.props)}` : ''}`).join('\n');