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,188 @@
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
+ });
@@ -0,0 +1,13 @@
1
+ export type FixturesConfig = {
2
+ command: string;
3
+ cwd?: string;
4
+ env?: Record<string, string>;
5
+ timeoutMs?: number;
6
+ };
7
+ export type Answer = Record<string, unknown>;
8
+ export declare class Fixtures {
9
+ #private;
10
+ constructor(config: FixturesConfig, root: string);
11
+ call(command: string, params: Record<string, unknown>): Promise<Answer>;
12
+ close(): void;
13
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Fixtures are how a case prepares and reads the backend, and lynkeus knows
3
+ * nothing about them: a project declares one long-lived command and lynkeus
4
+ * speaks JSON lines to it. One line in per step —
5
+ *
6
+ * {"command": "create_user", "params": {"plan": "pro"}, "env": {"PLAN": "pro"}}
7
+ *
8
+ * — and one line out, the step's answer: any JSON object, `"ok": false` and an
9
+ * `error` when it failed. `params` is the step as written; `env` is the same
10
+ * flattened to upper-case strings, for a server that hands them to a script.
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import path from 'node:path';
14
+ import readline from 'node:readline';
15
+ const envOf = (params) => Object.fromEntries(Object.entries(params)
16
+ .filter(([, v]) => v !== undefined && v !== null)
17
+ .map(([k, v]) => [k.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(), typeof v === 'object' ? JSON.stringify(v) : String(v)]));
18
+ export class Fixtures {
19
+ #config;
20
+ #root;
21
+ #child = null;
22
+ #lines = [];
23
+ #waiting = null;
24
+ #stderr = '';
25
+ constructor(config, root) {
26
+ this.#config = config;
27
+ this.#root = root;
28
+ }
29
+ #start() {
30
+ if (this.#child)
31
+ return this.#child;
32
+ const child = spawn(this.#config.command, {
33
+ shell: true,
34
+ // Its own process group, so closing takes the shell and what it started.
35
+ detached: true,
36
+ cwd: path.resolve(this.#root, this.#config.cwd ?? '.'),
37
+ env: { ...process.env, ...this.#config.env },
38
+ });
39
+ child.stderr.on('data', (chunk) => {
40
+ this.#stderr = (this.#stderr + chunk.toString()).slice(-4000);
41
+ });
42
+ const reader = readline.createInterface({ input: child.stdout });
43
+ reader.on('line', (line) => {
44
+ if (!line.trim().startsWith('{'))
45
+ return;
46
+ if (this.#waiting) {
47
+ const resolve = this.#waiting;
48
+ this.#waiting = null;
49
+ resolve(line);
50
+ }
51
+ else
52
+ this.#lines.push(line);
53
+ });
54
+ child.on('close', () => {
55
+ this.#child = null;
56
+ this.#waiting?.(null);
57
+ this.#waiting = null;
58
+ });
59
+ this.#child = child;
60
+ return child;
61
+ }
62
+ async call(command, params) {
63
+ const child = this.#start();
64
+ child.stdin.write(`${JSON.stringify({ command, params, env: envOf(params) })}\n`);
65
+ const timeoutMs = this.#config.timeoutMs ?? 120_000;
66
+ const line = await new Promise((resolve, reject) => {
67
+ const ready = this.#lines.shift();
68
+ if (ready !== undefined)
69
+ return resolve(ready);
70
+ const timer = setTimeout(() => {
71
+ this.#waiting = null;
72
+ reject(new Error(`${command}: the fixtures command did not answer within ${timeoutMs} ms`));
73
+ }, timeoutMs);
74
+ this.#waiting = (answer) => {
75
+ clearTimeout(timer);
76
+ resolve(answer);
77
+ };
78
+ });
79
+ if (line === null)
80
+ throw new Error(`${command}: the fixtures command ended${this.#stderr ? `: ${this.#stderr.trim().split('\n').slice(-3).join(' · ')}` : ''}`);
81
+ const answer = JSON.parse(line);
82
+ if (answer.ok === false)
83
+ throw new Error(`${command}: ${String(answer.error ?? answer.message ?? 'ok=false')}`);
84
+ return answer;
85
+ }
86
+ close() {
87
+ const child = this.#child;
88
+ this.#child = null;
89
+ if (!child)
90
+ return;
91
+ child.stdin.end();
92
+ try {
93
+ if (child.pid)
94
+ process.kill(-child.pid, 'SIGTERM');
95
+ }
96
+ catch {
97
+ // already gone
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,37 @@
1
+ export type StepValue = string | number | boolean | null | Record<string, unknown> | unknown[];
2
+ export type Step = {
3
+ verb: string;
4
+ value: StepValue;
5
+ on?: string;
6
+ };
7
+ export type Case = {
8
+ file: string;
9
+ id: string;
10
+ title?: string;
11
+ meta: Record<string, unknown>;
12
+ setup: Step[];
13
+ steps: Step[];
14
+ expected?: string;
15
+ };
16
+ /** A bare verb, or one `verb: value`. */
17
+ export declare const stepOf: (item: unknown, where?: string) => Step;
18
+ export declare const parseCase: (text: string, file: string) => Case;
19
+ export declare const readCases: (file: string) => Case[];
20
+ /** `a.b.0.c` into a value; `a.*.b` collects b from every element of the list a. */
21
+ export declare const dig: (json: unknown, dotted: string) => unknown;
22
+ export type Scope = Record<string, unknown>;
23
+ /** `{{alias.path}}` from what earlier steps answered, `{{random.letters}}`, `{{env.NAME}}`. */
24
+ export declare const interpolate: <T>(value: T, scope: Scope) => T;
25
+ export type Matcher = {
26
+ equals?: unknown;
27
+ near?: unknown;
28
+ gte?: unknown;
29
+ lte?: unknown;
30
+ present?: boolean;
31
+ absent?: boolean;
32
+ includes?: unknown;
33
+ matches?: string;
34
+ };
35
+ export declare const matcherOf: (params: Record<string, unknown>) => Matcher | undefined;
36
+ /** Whether a value read from the backend is what the case said it would be. */
37
+ export declare const matches: (got: unknown, m: Matcher) => boolean;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * A case is a markdown file a person can read: YAML frontmatter that says what
3
+ * it is, and a YAML body that says what to prepare (`setup`), what to do
4
+ * (`steps`) and what that proves (`expected`). Each step is one key: an
5
+ * `app.<verb>` drives the app, `assert` reads the backend, `exec` runs a shell
6
+ * command, and any other name is a fixture the project declared.
7
+ */
8
+ import fs from 'node:fs';
9
+ import { parse } from 'yaml';
10
+ /** A bare verb, or one `verb: value`. */
11
+ export const stepOf = (item, where = 'a step') => {
12
+ if (typeof item === 'string')
13
+ return { verb: item, value: null };
14
+ const keys = item && typeof item === 'object' ? Object.keys(item) : [];
15
+ if (keys.length !== 1)
16
+ throw new Error(`${where} must be one step, as \`verb: value\`, not ${JSON.stringify(item)}`);
17
+ return { verb: keys[0], value: item[keys[0]] };
18
+ };
19
+ const stepsOf = (list, file, section) => {
20
+ if (list === undefined || list === null)
21
+ return [];
22
+ if (!Array.isArray(list))
23
+ throw new Error(`${file}: ${section} is not a list`);
24
+ return list.flatMap((item, i) => {
25
+ const entry = item;
26
+ if (entry && typeof entry === 'object' && 'do' in entry) {
27
+ if (typeof entry.on !== 'string')
28
+ throw new Error(`${file}: ${section}[${i}] has \`do\` without \`on\``);
29
+ const inner = Array.isArray(entry.do) ? entry.do : [entry.do];
30
+ return inner.map((s, j) => ({ ...stepOf(s, `${file}: ${section}[${i}].do[${j}]`), on: entry.on }));
31
+ }
32
+ return [stepOf(item, `${file}: ${section}[${i}]`)];
33
+ });
34
+ };
35
+ export const parseCase = (text, file) => {
36
+ const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text);
37
+ if (!match)
38
+ throw new Error(`${file}: no frontmatter (a case starts with --- and its id)`);
39
+ const meta = (parse(match[1]) ?? {});
40
+ const body = (parse(match[2]) ?? {});
41
+ const id = typeof meta.id === 'string' ? meta.id : file.replace(/^.*\//, '').replace(/\.md$/, '');
42
+ return {
43
+ file,
44
+ id,
45
+ title: typeof meta.title === 'string' ? meta.title : undefined,
46
+ meta,
47
+ setup: stepsOf(body.setup, file, 'setup'),
48
+ steps: stepsOf(body.steps, file, 'steps'),
49
+ expected: typeof body.expected === 'string' ? body.expected.trim() : undefined,
50
+ };
51
+ };
52
+ const variantsOf = (text) => {
53
+ const head = /^---\n([\s\S]*?)\n---\n/.exec(text);
54
+ const matrix = head ? (parse(head[1]) ?? {}).matrix : undefined;
55
+ if (!matrix || typeof matrix !== 'object' || Array.isArray(matrix) || Object.keys(matrix).length === 0)
56
+ return [];
57
+ return Object.entries(matrix).reduce((combos, [key, values]) => combos.flatMap((combo) => (Array.isArray(values) ? values : [values]).map((v) => ({ ...combo, [key]: v }))), [{}]);
58
+ };
59
+ export const readCases = (file) => {
60
+ const text = fs.readFileSync(file, 'utf8');
61
+ const variants = variantsOf(text);
62
+ if (variants.length === 0)
63
+ return [parseCase(text, file)];
64
+ return variants.map((variant) => {
65
+ const filled = text.replace(/\{\{\s*matrix\.(\w+)\s*\}\}/g, (ref, key) => {
66
+ if (!(key in variant))
67
+ throw new Error(`${file}: ${ref} is not in its matrix`);
68
+ return String(variant[key]);
69
+ });
70
+ const c = parseCase(filled, file);
71
+ return {
72
+ ...c,
73
+ id: `${c.id}[${Object.entries(variant)
74
+ .map(([k, v]) => `${k}=${String(v)}`)
75
+ .join(',')}]`,
76
+ };
77
+ });
78
+ };
79
+ /** `a.b.0.c` into a value; `a.*.b` collects b from every element of the list a. */
80
+ export const dig = (json, dotted) => {
81
+ const star = dotted.indexOf('.*.');
82
+ if (star !== -1) {
83
+ const list = dig(json, dotted.slice(0, star));
84
+ return Array.isArray(list) ? list.map((item) => dig(item, dotted.slice(star + 3))) : undefined;
85
+ }
86
+ let current = json;
87
+ for (const key of dotted.split('.')) {
88
+ if (Array.isArray(current))
89
+ current = current[Number(key)];
90
+ else if (current && typeof current === 'object')
91
+ current = current[key];
92
+ else
93
+ return undefined;
94
+ }
95
+ return current;
96
+ };
97
+ const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
98
+ const random = (kind) => {
99
+ if (kind === 'letters')
100
+ return Array.from({ length: 6 }, () => LETTERS[Math.floor(Math.random() * 26)]).join('');
101
+ if (kind === 'digits')
102
+ return Array.from({ length: 6 }, () => Math.floor(Math.random() * 10)).join('');
103
+ return undefined;
104
+ };
105
+ /** `{{alias.path}}` from what earlier steps answered, `{{random.letters}}`, `{{env.NAME}}`. */
106
+ export const interpolate = (value, scope) => {
107
+ if (typeof value === 'string') {
108
+ const whole = /^\{\{\s*([\w-]+)\.([\w.*-]+)\s*\}\}$/.exec(value);
109
+ const lookup = (name, path) => {
110
+ if (name === 'random')
111
+ return random(path);
112
+ if (name === 'env')
113
+ return process.env[path];
114
+ if (!(name in scope))
115
+ throw new Error(`no alias '${name}' (add \`as: ${name}\` to the step that creates it)`);
116
+ return dig(scope[name], path);
117
+ };
118
+ // A value that is one reference keeps its type; inside a sentence it is text.
119
+ if (whole) {
120
+ const found = lookup(whole[1], whole[2]);
121
+ if (found === undefined || found === null)
122
+ throw new Error(`${value} is empty`);
123
+ return found;
124
+ }
125
+ return value.replace(/\{\{\s*([\w-]+)\.([\w.*-]+)\s*\}\}/g, (ref, name, path) => {
126
+ const found = lookup(name, path);
127
+ if (found === undefined || found === null)
128
+ throw new Error(`${ref} is empty`);
129
+ return typeof found === 'object' ? JSON.stringify(found) : String(found);
130
+ });
131
+ }
132
+ if (Array.isArray(value))
133
+ return value.map((v) => interpolate(v, scope));
134
+ if (value && typeof value === 'object')
135
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, interpolate(v, scope)]));
136
+ return value;
137
+ };
138
+ const MATCHERS = ['equals', 'near', 'gte', 'lte', 'present', 'absent', 'includes', 'matches'];
139
+ export const matcherOf = (params) => {
140
+ const found = MATCHERS.filter((m) => m in params);
141
+ return found.length ? Object.fromEntries(found.map((m) => [m, params[m]])) : undefined;
142
+ };
143
+ const num = (v) => {
144
+ const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : Number.NaN;
145
+ return Number.isFinite(n) ? n : undefined;
146
+ };
147
+ /** Whether a value read from the backend is what the case said it would be. */
148
+ export const matches = (got, m) => {
149
+ if ('equals' in m) {
150
+ const a = num(got);
151
+ const b = num(m.equals);
152
+ return a !== undefined && b !== undefined ? a === b : String(got) === String(m.equals);
153
+ }
154
+ if ('near' in m)
155
+ return num(got) !== undefined && num(m.near) !== undefined && Math.abs(num(got) - num(m.near)) <= 0.01;
156
+ if ('gte' in m)
157
+ return num(got) !== undefined && num(got) >= (num(m.gte) ?? Number.NaN);
158
+ if ('lte' in m)
159
+ return num(got) !== undefined && num(got) <= (num(m.lte) ?? Number.NaN);
160
+ if ('present' in m)
161
+ return m.present ? got !== undefined && got !== null : got === undefined || got === null;
162
+ if ('absent' in m)
163
+ return m.absent ? got === undefined || got === null : got !== undefined && got !== null;
164
+ if ('includes' in m)
165
+ return Array.isArray(got) ? got.some((v) => String(v).includes(String(m.includes))) : String(got ?? '').includes(String(m.includes));
166
+ if ('matches' in m)
167
+ return new RegExp(m.matches).test(String(got ?? ''));
168
+ return false;
169
+ };
@@ -0,0 +1,61 @@
1
+ import { type AppEvent, type RunEvent } from '../knowledge/events.js';
2
+ import { type Base, type Command } from '../registry.js';
3
+ import type { Fixtures, FixturesConfig } from './fixtures.js';
4
+ import { type Case, type StepValue } from './format.js';
5
+ export type CasesConfig = {
6
+ /** The folder of the file that declared this, set when the configuration is read. */
7
+ base?: string;
8
+ /** Where cases live (default `cases`). */
9
+ dir?: string;
10
+ /** Where the flows a case runs live (default `flows`). */
11
+ flows?: string;
12
+ /** The long-lived command that answers fixture steps, as JSON lines. */
13
+ fixtures?: FixturesConfig;
14
+ /** The fixture `assert` reads the backend through (default `inspect`). */
15
+ inspect?: string;
16
+ /** `{ "user": "user_id" }` turns `user: u` into `user_id: <u.user_id>`, and gives a step that names no user the latest one. */
17
+ refs?: Record<string, string>;
18
+ /** Steps this project adds: `{ "app.login": [...] }`. Inside, `{{param.x}}` is what the case passed. */
19
+ macros?: Record<string, unknown[]>;
20
+ /** What the app may put in the way of a step and the steps that get past it; a step whose own words match `unless` is about it, and is left alone. */
21
+ interruptions?: {
22
+ see: string;
23
+ do: unknown[];
24
+ unless?: string;
25
+ }[];
26
+ };
27
+ export type StepReport = {
28
+ step: string;
29
+ status: 'passed' | 'failed' | 'skipped';
30
+ ms: number;
31
+ error?: string;
32
+ };
33
+ export type CaseReport = {
34
+ id: string;
35
+ title?: string;
36
+ file: string;
37
+ result: 'passed' | 'failed';
38
+ ms: number;
39
+ steps: StepReport[];
40
+ /** When a step failed: the screen, the requests since the mark, and why. */
41
+ evidence?: {
42
+ screen?: string;
43
+ requests?: string[];
44
+ why?: string;
45
+ };
46
+ /** What the app told its analytics, each with the step that caused it, when the run asked for it. */
47
+ events?: RunEvent[];
48
+ };
49
+ export type RunOptions = {
50
+ base: Base;
51
+ config: CasesConfig;
52
+ fixtures?: Fixtures;
53
+ dry?: boolean;
54
+ events?: boolean;
55
+ log?: (line: string) => void;
56
+ };
57
+ /** What a buffer holds that it did not before, whichever end it grows from. */
58
+ export declare const newSince: (before: AppEvent[], now: AppEvent[]) => AppEvent[];
59
+ /** A step's value as the argv of the command it names: what matches a positional by name goes in place, the rest are flags. */
60
+ export declare const argvFor: (command: Command, value: StepValue) => string[];
61
+ export declare const runCase: (c: Case, options: RunOptions) => Promise<CaseReport>;