lynkeus 0.2.0 → 0.4.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 (134) hide show
  1. package/lib/cases/ahead.d.ts +31 -0
  2. package/lib/cases/ahead.js +90 -0
  3. package/lib/cases/fixtures.d.ts +12 -0
  4. package/lib/cases/fixtures.js +32 -0
  5. package/lib/cases/flakes.d.ts +28 -0
  6. package/lib/cases/flakes.js +61 -0
  7. package/lib/cases/format.d.ts +5 -0
  8. package/lib/cases/format.js +30 -4
  9. package/lib/cases/portClaim.d.ts +10 -0
  10. package/lib/cases/portClaim.js +55 -0
  11. package/lib/cases/runfile.d.ts +24 -0
  12. package/lib/cases/runfile.js +108 -0
  13. package/lib/cases/runner.d.ts +82 -3
  14. package/lib/cases/runner.js +394 -40
  15. package/lib/cases/templates.d.ts +2 -2
  16. package/lib/cases/templates.js +14 -4
  17. package/lib/cases/traces.d.ts +58 -0
  18. package/lib/cases/traces.js +250 -0
  19. package/lib/cli.js +28 -7
  20. package/lib/commands/cases.js +411 -42
  21. package/lib/commands/check.d.ts +6 -0
  22. package/lib/commands/check.js +8 -3
  23. package/lib/commands/doctor.js +8 -2
  24. package/lib/commands/drive.js +32 -8
  25. package/lib/commands/headless.js +132 -8
  26. package/lib/commands/init.d.ts +29 -0
  27. package/lib/commands/init.js +295 -33
  28. package/lib/commands/report.d.ts +97 -0
  29. package/lib/commands/report.js +228 -0
  30. package/lib/config.d.ts +26 -1
  31. package/lib/config.js +30 -0
  32. package/lib/device/agentVersion.d.ts +7 -0
  33. package/lib/device/agentVersion.js +12 -0
  34. package/lib/device/devices.d.ts +5 -0
  35. package/lib/device/devices.js +27 -0
  36. package/lib/device/driver.js +2 -2
  37. package/lib/flow/runner.js +33 -1
  38. package/lib/flow/types.d.ts +2 -1
  39. package/lib/knowledge/contrast.d.ts +45 -0
  40. package/lib/knowledge/contrast.js +114 -0
  41. package/lib/knowledge/errors.js +1 -1
  42. package/lib/knowledge/lint.d.ts +2 -0
  43. package/lib/knowledge/lint.js +13 -3
  44. package/lib/mcp/server.js +31 -2
  45. package/lib/registry.d.ts +5 -0
  46. package/lib/targets.d.ts +10 -1
  47. package/lib/targets.js +16 -3
  48. package/package.json +10 -6
  49. package/src/cases/ahead.ts +111 -0
  50. package/src/cases/fixtures.ts +37 -0
  51. package/src/cases/flakes.ts +67 -0
  52. package/src/cases/format.ts +31 -6
  53. package/src/cases/portClaim.ts +52 -0
  54. package/src/cases/runfile.ts +119 -0
  55. package/src/cases/runner.ts +454 -46
  56. package/src/cases/templates.ts +14 -4
  57. package/src/cases/traces.ts +298 -0
  58. package/src/cli.ts +29 -7
  59. package/src/commands/cases.ts +428 -41
  60. package/src/commands/check.ts +11 -4
  61. package/src/commands/doctor.ts +9 -2
  62. package/src/commands/drive.ts +31 -8
  63. package/src/commands/headless.ts +143 -8
  64. package/src/commands/init.ts +338 -30
  65. package/src/commands/report.ts +272 -0
  66. package/src/config.ts +39 -2
  67. package/src/device/agentVersion.ts +15 -0
  68. package/src/device/devices.ts +28 -0
  69. package/src/device/driver.ts +2 -2
  70. package/src/flow/runner.ts +35 -1
  71. package/src/flow/types.ts +2 -2
  72. package/src/knowledge/contrast.ts +135 -0
  73. package/src/knowledge/errors.ts +1 -1
  74. package/src/knowledge/lint.ts +15 -4
  75. package/src/mcp/server.ts +31 -2
  76. package/src/registry.ts +2 -0
  77. package/src/targets.ts +17 -3
  78. package/lib/__tests__/args.test.d.ts +0 -1
  79. package/lib/__tests__/args.test.js +0 -69
  80. package/lib/__tests__/check.test.d.ts +0 -1
  81. package/lib/__tests__/check.test.js +0 -60
  82. package/lib/__tests__/init.test.d.ts +0 -1
  83. package/lib/__tests__/init.test.js +0 -42
  84. package/lib/__tests__/record.test.d.ts +0 -1
  85. package/lib/__tests__/record.test.js +0 -44
  86. package/lib/__tests__/snapshot.test.d.ts +0 -1
  87. package/lib/__tests__/snapshot.test.js +0 -47
  88. package/lib/__tests__/targets.test.d.ts +0 -1
  89. package/lib/__tests__/targets.test.js +0 -127
  90. package/lib/cases/__tests__/format.test.d.ts +0 -1
  91. package/lib/cases/__tests__/format.test.js +0 -61
  92. package/lib/cases/__tests__/runner.test.d.ts +0 -1
  93. package/lib/cases/__tests__/runner.test.js +0 -188
  94. package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
  95. package/lib/knowledge/__tests__/edges.test.js +0 -25
  96. package/lib/knowledge/__tests__/events.test.d.ts +0 -1
  97. package/lib/knowledge/__tests__/events.test.js +0 -39
  98. package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
  99. package/lib/knowledge/__tests__/extract.test.js +0 -69
  100. package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
  101. package/lib/knowledge/__tests__/frontier.test.js +0 -57
  102. package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
  103. package/lib/knowledge/__tests__/funnels.test.js +0 -40
  104. package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
  105. package/lib/knowledge/__tests__/lint.test.js +0 -53
  106. package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
  107. package/lib/knowledge/__tests__/perf.test.js +0 -34
  108. package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
  109. package/lib/knowledge/__tests__/plan.test.js +0 -35
  110. package/lib/knowledge/__tests__/query.test.d.ts +0 -1
  111. package/lib/knowledge/__tests__/query.test.js +0 -55
  112. package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
  113. package/lib/knowledge/__tests__/screens.test.js +0 -30
  114. package/lib/knowledge/__tests__/why.test.d.ts +0 -1
  115. package/lib/knowledge/__tests__/why.test.js +0 -28
  116. package/src/__tests__/args.test.ts +0 -82
  117. package/src/__tests__/check.test.ts +0 -71
  118. package/src/__tests__/init.test.ts +0 -52
  119. package/src/__tests__/record.test.ts +0 -49
  120. package/src/__tests__/snapshot.test.ts +0 -51
  121. package/src/__tests__/targets.test.ts +0 -132
  122. package/src/cases/__tests__/format.test.ts +0 -76
  123. package/src/cases/__tests__/runner.test.ts +0 -230
  124. package/src/knowledge/__tests__/edges.test.ts +0 -34
  125. package/src/knowledge/__tests__/events.test.ts +0 -45
  126. package/src/knowledge/__tests__/extract.test.ts +0 -88
  127. package/src/knowledge/__tests__/frontier.test.ts +0 -77
  128. package/src/knowledge/__tests__/funnels.test.ts +0 -58
  129. package/src/knowledge/__tests__/lint.test.ts +0 -66
  130. package/src/knowledge/__tests__/perf.test.ts +0 -41
  131. package/src/knowledge/__tests__/plan.test.ts +0 -52
  132. package/src/knowledge/__tests__/query.test.ts +0 -74
  133. package/src/knowledge/__tests__/screens.test.ts +0 -37
  134. package/src/knowledge/__tests__/why.test.ts +0 -40
@@ -1,61 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
3
- import fs from 'node:fs';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- import { dig, interpolate, matcherOf, matches, parseCase, readCases } from '../format.js';
7
- const CASE = `---
8
- id: gift
9
- title: "A gift shows up"
10
- ---
11
- setup:
12
- - create_user: { as: u, name: "Ana" }
13
- steps:
14
- - app.press: "#open"
15
- - app.back
16
- - assert: { user: u, path: score, equals: 25 }
17
- expected: |
18
- The score is 25.
19
- `;
20
- test('a case is its frontmatter, its setup, its steps and what it expects', () => {
21
- const c = parseCase(CASE, 'cases/gift.md');
22
- assert.equal(c.id, 'gift');
23
- assert.deepEqual(c.setup, [{ verb: 'create_user', value: { as: 'u', name: 'Ana' } }]);
24
- assert.deepEqual(c.steps.map((s) => s.verb), ['app.press', 'app.back', 'assert']);
25
- assert.equal(c.expected, 'The score is 25.');
26
- });
27
- test('a file without frontmatter, or a step with two verbs, is refused with its place', () => {
28
- assert.throws(() => parseCase('steps: []', 'x.md'), /x\.md: no frontmatter/);
29
- assert.throws(() => parseCase('---\nid: x\n---\nsteps:\n - { a: 1, b: 2 }\n', 'x.md'), /steps\[0\] must be one step/);
30
- });
31
- test('a path reads into lists, and a star collects from every element', () => {
32
- const json = { accounts: [{ code: 'usd' }, { code: 'eur' }], user: { id: 7 } };
33
- assert.equal(dig(json, 'accounts.1.code'), 'eur');
34
- assert.deepEqual(dig(json, 'accounts.*.code'), ['usd', 'eur']);
35
- assert.equal(dig(json, 'user.name.first'), undefined);
36
- });
37
- test('a reference alone keeps its type; inside text it is text; a missing one says which', () => {
38
- const scope = { u: { user_id: 7, name: 'Ana' } };
39
- assert.deepEqual(interpolate({ id: '{{u.user_id}}', hi: 'hi {{u.name}}', n: 3 }, scope), { id: 7, hi: 'hi Ana', n: 3 });
40
- assert.match(interpolate('x{{random.letters}}', scope), /^x[a-z]{6}$/);
41
- assert.throws(() => interpolate('{{v.user_id}}', scope), /no alias 'v'/);
42
- assert.throws(() => interpolate('{{u.phone}}', scope), /\{\{u\.phone\}\} is empty/);
43
- });
44
- test('matchers compare numbers as numbers and lists by any element', () => {
45
- assert.equal(matches('25.0', { equals: 25 }), true);
46
- assert.equal(matches('abc', { equals: 'abc' }), true);
47
- assert.equal(matches(['usd', 'bridge_usd'], { includes: 'bridge' }), true);
48
- assert.equal(matches(9, { gte: 10 }), false);
49
- assert.equal(matches(null, { absent: true }), true);
50
- assert.equal(matches('AUTORIZADO', { matches: '^AUT' }), true);
51
- assert.deepEqual(matcherOf({ path: 'a', equals: 1, user: 'u' }), { equals: 1 });
52
- assert.equal(matcherOf({ path: 'a' }), undefined);
53
- });
54
- test('a case with a matrix runs once per combination, named after it', () => {
55
- const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-matrix-')), 'm.md');
56
- fs.writeFileSync(file, '---\nid: m\nmatrix: { locale: [en, fr], login: [phone] }\n---\nsteps:\n - create_user: { locale: "{{matrix.locale}}" }\n');
57
- const cases = readCases(file);
58
- assert.deepEqual(cases.map((c) => c.id), ['m[locale=en,login=phone]', 'm[locale=fr,login=phone]']);
59
- assert.deepEqual(cases[1].setup, []);
60
- assert.deepEqual(cases[1].steps[0].value, { locale: 'fr' });
61
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,188 +0,0 @@
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 { after, test } from 'node:test';
6
- import { driveCommands } from '../../commands/drive.js';
7
- import { osCommands } from '../../commands/os.js';
8
- import { find, register } from '../../registry.js';
9
- import { Fixtures } from '../fixtures.js';
10
- import { parseCase } from '../format.js';
11
- import { argvFor, newSince, runCase } from '../runner.js';
12
- import { FIXTURES } from '../templates.js';
13
- register([...driveCommands, ...osCommands]);
14
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-cases-'));
15
- fs.writeFileSync(path.join(root, 'fixtures.mjs'), FIXTURES);
16
- const fixtures = new Fixtures({ command: 'node fixtures.mjs' }, root);
17
- after(() => fixtures.close());
18
- // An app with one button that, pressed, shows the points the fixtures hold.
19
- const fakeApp = () => {
20
- const calls = [];
21
- let shown = 'Hello';
22
- const screen = () => ({
23
- route: 'Profile.Main',
24
- path: ['Profile.Main'],
25
- commit: 1,
26
- busy: false,
27
- window: { w: 390, h: 844 },
28
- costMs: 1,
29
- elements: [
30
- { i: 0, kind: 'button', testId: 'refresh', enabled: true, depth: 0, frame: { x: 0, y: 0, w: 100, h: 40 } },
31
- { i: 1, kind: 'text', text: shown, enabled: true, depth: 0, frame: { x: 0, y: 50, w: 100, h: 20 } },
32
- ],
33
- });
34
- const server = {
35
- connected: true,
36
- call: async (method, params) => {
37
- calls.push({ method, params });
38
- if (method === 'find')
39
- return screen().elements.find((e) => e.testId === params.testId) ?? null;
40
- return null;
41
- },
42
- };
43
- const device = {
44
- server,
45
- screen: async () => screen(),
46
- trace: async () => ({ last: 0, events: [{ seq: 1, t: 0, kind: 'request', method: 'GET', url: 'http://x/api/points', status: 200 }] }),
47
- press: async (params) => {
48
- calls.push({ method: 'press', params });
49
- shown = '25 points';
50
- return { x: 0, y: 0, mode: 'native' };
51
- },
52
- waitFor: async () => ({ waitedMs: 0 }),
53
- idle: async () => ({ settled: true }),
54
- command: async (name) => (name === 'events' ? [{ name: 'points_viewed', properties: { points: 25 } }] : {}),
55
- };
56
- const base = {
57
- root,
58
- config: {},
59
- attached: true,
60
- err: () => undefined,
61
- graph: () => {
62
- throw new Error('no graph');
63
- },
64
- device: async () => device,
65
- };
66
- return { base, calls };
67
- };
68
- const config = { refs: { user: 'user_id' }, inspect: 'inspect' };
69
- test('a case prepares the backend, drives the app and reads both', async () => {
70
- const c = parseCase(`---
71
- id: gift
72
- ---
73
- setup:
74
- - create_user: { as: u, name: "Ana" }
75
- steps:
76
- - app.see: { text: "Hello" }
77
- - give_points: { user: u, points: 25 }
78
- - app.press: "#refresh"
79
- - app.see: { text: "25 points", request: "GET /api/points", event: points_viewed, props: { points: 25 } }
80
- - app.see: { text: "Hello", absent: true }
81
- - assert: { user: u, path: points, equals: 25 }
82
- - assert: { path: name, equals: "Ana" }
83
- `, 'gift.md');
84
- const { base, calls } = fakeApp();
85
- const report = await runCase(c, { base, config, fixtures });
86
- assert.deepEqual(report.steps.map((s) => [s.status, s.error]), Array(8).fill(['passed', undefined]));
87
- assert.equal(calls.filter((x) => x.method === 'press').length, 1);
88
- });
89
- test('the first step that fails stops the case and leaves the evidence', async () => {
90
- const c = parseCase('---\nid: red\n---\nsteps:\n - app.see: { text: "Goodbye", timeoutMs: 200 }\n - app.press: "#refresh"\n', 'red.md');
91
- const { base, calls } = fakeApp();
92
- const report = await runCase(c, { base, config, fixtures });
93
- assert.equal(report.result, 'failed');
94
- assert.deepEqual(report.steps.map((s) => s.status), ['failed', 'skipped']);
95
- assert.match(report.steps[0].error, /not on screen: "Goodbye"/);
96
- assert.match(report.evidence.screen, /button #refresh/);
97
- assert.equal(calls.filter((x) => x.method === 'press').length, 0);
98
- });
99
- test('an optional press of something that is not there is skipped, not failed', async () => {
100
- const c = parseCase('---\nid: opt\n---\nsteps:\n - app.press: { target: "#dismiss", optional: true }\n', 'opt.md');
101
- const { base } = fakeApp();
102
- const device = await base.device();
103
- device.waitFor = async () => {
104
- throw new Error('Timed out');
105
- };
106
- const report = await runCase(c, { base, config, fixtures });
107
- assert.deepEqual(report.steps.map((s) => s.status), ['skipped']);
108
- });
109
- test('a macro is the steps a project gave a name to, with what the case passed', async () => {
110
- const c = parseCase('---\nid: m\n---\nsetup:\n - create_user: { as: u }\nsteps:\n - app.enter: { as: u, then: "#refresh" }\n - app.enter: { as: u }\n', 'm.md');
111
- const { base, calls } = fakeApp();
112
- const macros = {
113
- 'app.enter': [{ assert: { user_id: '{{param.as.user_id}}', path: 'user_id', matches: '^u_' } }, { when: 'then', do: { 'app.press': '{{param.then}}' } }],
114
- };
115
- const report = await runCase(c, { base, config: { ...config, macros }, fixtures });
116
- assert.equal(report.result, 'passed', JSON.stringify(report.steps));
117
- assert.equal(calls.filter((x) => x.method === 'press').length, 1);
118
- });
119
- test("a step that is neither the app's nor a declared fixture says so", async () => {
120
- const c = parseCase('---\nid: f\n---\nsteps:\n - teleport: { to: "moon" }\n', 'f.md');
121
- const { base } = fakeApp();
122
- const report = await runCase(c, { base, config, fixtures });
123
- assert.match(report.steps[0].error, /no fixture teleport/);
124
- });
125
- test('a step becomes the argv of the command it names', () => {
126
- assert.deepEqual(argvFor(find('type'), { text: 'ana', target: '#search', submit: true }), ['type', 'ana', '#search', '--submit']);
127
- assert.deepEqual(argvFor(find('wait'), { target: '#row-25', scroll: true, timeoutMs: 9000 }), ['wait', '#row-25', '--scroll', '--timeout', '9000']);
128
- assert.deepEqual(argvFor(find('press'), { target: '#like', within: 'hi from Ana' }), ['press', '#like', '--in', 'hi from Ana']);
129
- assert.deepEqual(argvFor(find('mock'), { request: 'POST /pay', lost: true, times: 1 }), ['mock', 'POST /pay', '--lost', '--times', '1']);
130
- assert.deepEqual(argvFor(find('swipe'), { from: '10,20', to: '10,300', durationMs: 300 }), [
131
- 'swipe',
132
- '--from',
133
- '10,20',
134
- '--to',
135
- '10,300',
136
- '--duration',
137
- '300',
138
- ]);
139
- assert.deepEqual(argvFor(find('mock'), 'clear'), ['mock', '--clear']);
140
- assert.throws(() => argvFor(find('press'), { target: '#x', sideways: true }), /press has no sideways/);
141
- });
142
- test('what a buffer gained is found whichever end it grows from', () => {
143
- const a = { name: 'a' };
144
- const b = { name: 'b' };
145
- const c = { name: 'c' };
146
- assert.deepEqual(newSince([a], [a, b, c]), [b, c]);
147
- assert.deepEqual(newSince([a], [c, b, a]), [b, c]);
148
- assert.deepEqual(newSince([a, b], [a, b]), []);
149
- assert.deepEqual(newSince([a, b], [c]), [c]);
150
- });
151
- test('a run that asks for events keeps each with the step that caused it', async () => {
152
- const c = parseCase('---\nid: ev\n---\nsteps:\n - app.see: { text: "Hello" }\n - app.press: "#refresh"\n', 'ev.md');
153
- const { base } = fakeApp();
154
- const device = await base.device();
155
- let buffer = [];
156
- device.command = async () => buffer;
157
- const press = device.press.bind(device);
158
- device.press = async (p) => {
159
- buffer = [{ name: 'refreshed', properties: { by: 'button' } }, ...buffer];
160
- return press(p);
161
- };
162
- const report = await runCase(c, { base, config, fixtures, events: true });
163
- assert.deepEqual(report.events, [{ name: 'refreshed', props: { by: 'button' }, step: 2, route: 'Profile.Main' }]);
164
- });
165
- test('what the app puts in the way of a step is dealt with before it, unless the step is about it', async () => {
166
- const { base, calls } = fakeApp();
167
- const interruptions = [{ see: '#refresh', unless: 'refresh', do: [{ 'app.press': '#refresh' }] }];
168
- const c = parseCase('---\nid: t\n---\nsteps:\n - app.see: { testId: refresh }\n - app.see: { text: "25 points" }\n', 't.md');
169
- const report = await runCase(c, { base, config: { ...config, interruptions }, fixtures });
170
- assert.equal(report.result, 'passed');
171
- assert.equal(calls.filter((call) => call.method === 'press').length > 0, true);
172
- });
173
- test('a dry run names the step nobody answers and the argument a command does not take', async () => {
174
- const { base } = fakeApp();
175
- const c = parseCase('---\nid: t\n---\nsteps:\n - app.teleport: somewhere\n - app.clock: { sideways: 1 }\n - app.clock: { advanceMs: 1000 }\n', 't.md');
176
- const report = await runCase(c, { base, config, fixtures, dry: true });
177
- assert.equal(report.result, 'failed');
178
- assert.deepEqual(report.steps.map((s) => s.status), ['failed', 'failed', 'passed']);
179
- });
180
- test('a step kept to a device is skipped on a host, and one kept to the host runs there', async () => {
181
- const { base, calls } = fakeApp();
182
- const c = parseCase('---\nid: t\n---\nsteps:\n - on: device\n do:\n - app.press: "#nowhere"\n - on: headless\n do: { app.press: "#refresh" }\n - app.see: { text: "25 points" }\n', 't.md');
183
- assert.equal(c.steps[0].on, 'device');
184
- const report = await runCase(c, { base, config, fixtures });
185
- assert.equal(report.result, 'passed', JSON.stringify(report.steps));
186
- assert.deepEqual(report.steps.map((s) => s.status), ['skipped', 'passed', 'passed']);
187
- assert.equal(calls.filter((call) => call.method === 'press').length, 1);
188
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,25 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,39 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,69 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,57 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,40 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,53 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,34 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,35 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
3
- import { describePlan, plan } from '../plan.js';
4
- const graph = (routes) => ({
5
- app: 'test',
6
- root: '/tmp',
7
- extractedAt: '2026-01-01',
8
- screens: Object.fromEntries(routes.map((route) => [route, { route, component: route, file: null, declaredIn: 'nav.tsx' }])),
9
- files: {},
10
- warnings: [],
11
- });
12
- const edge = (e) => ({ seen: 1, last: '2026-01-01', ...e });
13
- test('a sheet is a place the directions can reach', () => {
14
- const steps = plan(graph(['Settings.Main', 'Account.Delete']), 'Settings.Main', 'Account.Delete', [
15
- edge({ from: 'Settings.Main', to: 'Settings.Main', via: 'account-button', toLayer: 'account-button' }),
16
- edge({ from: 'Settings.Main', to: 'Account.Delete', via: 'delete-account-button', fromLayer: 'account-button' }),
17
- ]);
18
- assert.deepEqual(steps?.map((s) => [s.from, s.press, s.to]), [
19
- ['Settings.Main', 'account-button', 'Settings.Main#account-button'],
20
- ['Settings.Main#account-button', 'delete-account-button', 'Account.Delete'],
21
- ]);
22
- assert.match(describePlan(steps), /opens a layer/);
23
- });
24
- test('what a screen offers does not apply while a sheet covers it', () => {
25
- const steps = plan(graph(['Settings.Main', 'Profile.Edit']), 'Settings.Main#account-button', 'Profile.Edit', [
26
- edge({ from: 'Settings.Main', to: 'Profile.Edit', via: 'profile-button' }),
27
- ]);
28
- assert.equal(steps, null);
29
- });
30
- test('asking for a route accepts arriving with something open over it', () => {
31
- const steps = plan(graph(['Settings.Main', 'Checkout.Main']), 'Settings.Main', 'Checkout.Main', [
32
- edge({ from: 'Settings.Main', to: 'Checkout.Main', via: 'send-button', toLayer: 'send-button' }),
33
- ]);
34
- assert.deepEqual(steps?.map((s) => s.to), ['Checkout.Main#send-button']);
35
- });
@@ -1 +0,0 @@
1
- export {};