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,132 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import type { Screen } from 'lynkeus-protocol';
5
+
6
+ import { describeScreen } from '../device/driver.js';
7
+ import { parseTarget, scopedTarget } from '../targets.js';
8
+
9
+ test('a #-prefixed string is a testId', () => {
10
+ assert.deepEqual(parseTarget('#next-button'), { testId: 'next-button' });
11
+ });
12
+ test('an x,y pair is a point', () => {
13
+ assert.deepEqual(parseTarget('100,200'), { x: 100, y: 200 });
14
+ });
15
+ test('a bare integer is an index', () => {
16
+ assert.deepEqual(parseTarget('12'), { i: 12 });
17
+ });
18
+ test('anything else is text', () => {
19
+ assert.deepEqual(parseTarget('Some label'), { text: 'Some label' });
20
+ });
21
+ test('a testId that contains a comma is still a testId, not a point', () => {
22
+ assert.deepEqual(parseTarget('#a,b'), { testId: 'a,b' });
23
+ });
24
+
25
+ test('describeScreen prints the value a QR image carries', () => {
26
+ const screen = {
27
+ route: 'X',
28
+ path: ['X'],
29
+ commit: 1,
30
+ busy: false,
31
+ window: { w: 390, h: 844 },
32
+ costMs: 1,
33
+ elements: [{ i: 0, kind: 'image', frame: { x: 0, y: 0, w: 168, h: 169 }, enabled: true, depth: 0, accessibilityLabel: 'qr', value: 'QR-TEST-0001' }],
34
+ } as unknown as Screen;
35
+ assert.match(describeScreen(screen), /image .*value="QR-TEST-0001"/);
36
+ });
37
+ test('describeScreen lists a view with a testID and leaves plain layout out', () => {
38
+ const screen = {
39
+ route: 'X',
40
+ path: ['X'],
41
+ commit: 1,
42
+ busy: false,
43
+ window: { w: 390, h: 844 },
44
+ costMs: 1,
45
+ elements: [
46
+ { i: 0, kind: 'view', frame: { x: 0, y: 0, w: 390, h: 844 }, enabled: true, depth: 0 },
47
+ { i: 1, kind: 'view', frame: { x: 0, y: 0, w: 390, h: 400 }, enabled: true, depth: 1, testId: 'intro-page-advances' },
48
+ ],
49
+ } as unknown as Screen;
50
+ const text = describeScreen(screen);
51
+ assert.match(text, /view +#intro-page-advances/);
52
+ assert.doesNotMatch(text, /^0? ?view$/m);
53
+ });
54
+ test('describeScreen leaves out what is not presented, counts it, and lists it with --all', () => {
55
+ const el = (i: number, over: object) => ({ i, kind: 'button', frame: { x: 0, y: 0, w: 100, h: 40 }, enabled: true, depth: 0, ...over });
56
+ const screen = {
57
+ route: 'X',
58
+ path: ['X'],
59
+ commit: 7,
60
+ busy: false,
61
+ window: { w: 390, h: 844 },
62
+ costMs: 3,
63
+ elements: [
64
+ el(0, { testId: 'tab-a', hidden: 'a11y' }),
65
+ el(1, { testId: 'behind', hidden: 'behind-overlay' }),
66
+ el(2, { testId: 'slide', hidden: 'inert' }),
67
+ el(3, { testId: 'close', enabled: false }),
68
+ ],
69
+ } as unknown as Screen;
70
+ const text = describeScreen(screen);
71
+ assert.equal(text.split('\n')[0] ?? '', 'X · 2 hidden: 1 a11y, 1 behind-overlay');
72
+ assert.doesNotMatch(text, /#tab-a|#behind/);
73
+ assert.match(text, /^button #slide ~inert$/m);
74
+ assert.match(text, /^button #close !disabled$/m);
75
+ assert.match(describeScreen(screen, { all: true }), /#behind ~behind-overlay/);
76
+ assert.match(describeScreen(screen, { frames: true }), /#slide ~inert @50,20$/m);
77
+ assert.match(describeScreen(screen, { verbose: true }).split('\n')[0] ?? '', /commit=7 3ms/);
78
+ });
79
+ test("describeScreen leaves a button's icon out", () => {
80
+ const screen = {
81
+ route: 'X',
82
+ path: ['X'],
83
+ commit: 1,
84
+ busy: false,
85
+ window: { w: 390, h: 844 },
86
+ costMs: 1,
87
+ elements: [
88
+ { i: 0, kind: 'button', frame: { x: 0, y: 0, w: 100, h: 40 }, enabled: true, depth: 0, testId: 'open' },
89
+ { i: 1, kind: 'view', frame: { x: 0, y: 0, w: 20, h: 20 }, enabled: true, depth: 1, testId: 'SvgArrow', parent: 0 },
90
+ { i: 2, kind: 'view', frame: { x: 0, y: 50, w: 20, h: 20 }, enabled: true, depth: 0, testId: 'page-marker' },
91
+ ],
92
+ } as unknown as Screen;
93
+ assert.doesNotMatch(describeScreen(screen), /SvgArrow/);
94
+ assert.match(describeScreen(screen), /#page-marker/);
95
+ assert.match(describeScreen(screen, { all: true }), /SvgArrow/);
96
+ });
97
+ test('describeScreen gives an index only to what cannot be named apart', () => {
98
+ const el = (i: number, over: object) => ({ i, kind: 'button', frame: { x: 0, y: 0, w: 100, h: 40 }, enabled: true, depth: 0, ...over });
99
+ const screen = {
100
+ route: 'X',
101
+ path: ['X'],
102
+ commit: 1,
103
+ busy: false,
104
+ window: { w: 390, h: 844 },
105
+ costMs: 1,
106
+ elements: [
107
+ el(0, { testId: 'save' }),
108
+ el(1, { testId: 'like' }),
109
+ el(2, { testId: 'like' }),
110
+ el(3, { kind: 'text', text: 'Hello' }),
111
+ el(4, { kind: 'view', testId: 'marker', accessibilityLabel: 'marker' }),
112
+ el(5, {}),
113
+ ],
114
+ } as unknown as Screen;
115
+ assert.deepEqual(describeScreen(screen).split('\n').slice(1), [
116
+ 'button #save',
117
+ '1 button #like',
118
+ '2 button #like',
119
+ 'text "Hello"',
120
+ 'view #marker',
121
+ '5 button',
122
+ ]);
123
+ });
124
+ test('a [n] suffix picks the nth match of a testId or text', () => {
125
+ assert.deepEqual(parseTarget('#field[2]'), { testId: 'field', nth: 2 });
126
+ assert.deepEqual(parseTarget('Ciudad[0]'), { text: 'Ciudad', nth: 0 });
127
+ });
128
+ test('--in and --nth narrow a target', () => {
129
+ assert.deepEqual(scopedTarget('#like-message-button', { in: 'hi from Ana' }), { testId: 'like-message-button', within: { text: 'hi from Ana' } });
130
+ assert.deepEqual(scopedTarget('#like-message-button', { in: '#feed', nth: 2 }), { testId: 'like-message-button', within: { testId: 'feed' }, nth: 2 });
131
+ assert.deepEqual(scopedTarget('100,200', { in: '#feed' }), { x: 100, y: 200 });
132
+ });
package/src/args.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Booleans never consume the next token (`--direct a.ts` keeps `a.ts`),
3
+ * `--no-<flag>` clears one, and a repeated list flag collects.
4
+ */
5
+ export type FlagSpec = {
6
+ type: 'string' | 'number' | 'boolean' | 'list';
7
+ help: string;
8
+ default?: string | number | boolean;
9
+ /** The placeholder in usage: `--timeout <ms>`. */
10
+ value?: string;
11
+ };
12
+
13
+ export type PositionalSpec = {
14
+ name: string;
15
+ help: string;
16
+ required?: boolean;
17
+ /** Takes every argument left. */
18
+ rest?: boolean;
19
+ };
20
+
21
+ export type FlagValues = Record<string, string | number | boolean | string[] | undefined>;
22
+
23
+ export class UsageError extends Error {}
24
+
25
+ export const GLOBAL_FLAGS: Record<string, FlagSpec> = {
26
+ root: { type: 'string', help: "the app's folder (default: the current one)", value: 'dir' },
27
+ device: { type: 'string', help: 'simulator udid or name', value: 'udid|name' },
28
+ app: { type: 'string', help: 'bundle id, for launching and simulator commands', value: 'bundleId' },
29
+ launch: { type: 'boolean', help: 'relaunch the app before attaching (needs --app)' },
30
+ 'attach-timeout': { type: 'number', help: 'how long to wait for the app to attach', default: 15000, value: 'ms' },
31
+ json: { type: 'boolean', help: 'machine-readable output when the command has one' },
32
+ help: { type: 'boolean', help: 'this text' },
33
+ };
34
+
35
+ export const parseArgs = (
36
+ argv: string[],
37
+ positionals: PositionalSpec[] = [],
38
+ flags: Record<string, FlagSpec> = {},
39
+ ): { args: string[]; flags: FlagValues; passthrough: string[] } => {
40
+ const specs = { ...GLOBAL_FLAGS, ...flags };
41
+ const out: FlagValues = {};
42
+ const args: string[] = [];
43
+ let passthrough: string[] = [];
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const token = argv[i]!;
46
+ if (token === '--') {
47
+ passthrough = argv.slice(i + 1);
48
+ break;
49
+ }
50
+ if (token.startsWith('--') && token.length > 2) {
51
+ const eq = token.indexOf('=');
52
+ let name = eq === -1 ? token.slice(2) : token.slice(2, eq);
53
+ let inline = eq === -1 ? undefined : token.slice(eq + 1);
54
+ let negated = false;
55
+ if (!specs[name] && name.startsWith('no-') && specs[name.slice(3)]?.type === 'boolean') {
56
+ name = name.slice(3);
57
+ negated = true;
58
+ }
59
+ const spec = specs[name];
60
+ if (!spec)
61
+ throw new UsageError(
62
+ `unknown flag --${name}; flags: ${Object.keys(specs)
63
+ .map((f) => `--${f}`)
64
+ .join(' ')}`,
65
+ );
66
+ if (spec.type === 'boolean') {
67
+ out[name] = !negated && (inline === undefined ? true : inline !== 'false');
68
+ continue;
69
+ }
70
+ if (inline === undefined) {
71
+ inline = argv[i + 1];
72
+ if (inline === undefined || (inline.startsWith('--') && inline.length > 2))
73
+ throw new UsageError(`--${name} needs a value${spec.value ? ` <${spec.value}>` : ''}`);
74
+ i++;
75
+ }
76
+ if (spec.type === 'number') {
77
+ const n = Number(inline);
78
+ if (Number.isNaN(n)) throw new UsageError(`--${name} expects a number, got "${inline}"`);
79
+ out[name] = n;
80
+ } else if (spec.type === 'list') {
81
+ out[name] = [...((out[name] as string[] | undefined) ?? []), inline];
82
+ } else out[name] = inline;
83
+ continue;
84
+ }
85
+ args.push(token);
86
+ }
87
+ for (const [name, spec] of Object.entries(specs)) if (out[name] === undefined && spec.default !== undefined) out[name] = spec.default;
88
+ const required = positionals.filter((p) => p.required);
89
+ if (args.length < required.length)
90
+ throw new UsageError(
91
+ `missing ${required
92
+ .slice(args.length)
93
+ .map((p) => `<${p.name}>`)
94
+ .join(' ')}`,
95
+ );
96
+ const last = positionals[positionals.length - 1];
97
+ if (!last?.rest && args.length > positionals.length) throw new UsageError(`unexpected argument "${args[positionals.length]}"`);
98
+ return { args, flags: out, passthrough };
99
+ };
100
+
101
+ export const pairs = (list: string[] | undefined): Record<string, string> => {
102
+ const out: Record<string, string> = {};
103
+ for (const item of list ?? []) {
104
+ const eq = item.indexOf('=');
105
+ if (eq > 0) out[item.slice(0, eq)] = item.slice(eq + 1);
106
+ }
107
+ return out;
108
+ };
109
+
110
+ /** Double quotes group; everything else splits on spaces. */
111
+ export const splitLine = (line: string): string[] => (line.match(/"[^"]*"|\S+/g) ?? []).map((a) => a.replace(/^"|"$/g, ''));
@@ -0,0 +1,76 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ import { dig, interpolate, matcherOf, matches, parseCase, readCases } from '../format.js';
9
+
10
+ const CASE = `---
11
+ id: gift
12
+ title: "A gift shows up"
13
+ ---
14
+ setup:
15
+ - create_user: { as: u, name: "Ana" }
16
+ steps:
17
+ - app.press: "#open"
18
+ - app.back
19
+ - assert: { user: u, path: score, equals: 25 }
20
+ expected: |
21
+ The score is 25.
22
+ `;
23
+
24
+ test('a case is its frontmatter, its setup, its steps and what it expects', () => {
25
+ const c = parseCase(CASE, 'cases/gift.md');
26
+ assert.equal(c.id, 'gift');
27
+ assert.deepEqual(c.setup, [{ verb: 'create_user', value: { as: 'u', name: 'Ana' } }]);
28
+ assert.deepEqual(
29
+ c.steps.map((s) => s.verb),
30
+ ['app.press', 'app.back', 'assert'],
31
+ );
32
+ assert.equal(c.expected, 'The score is 25.');
33
+ });
34
+
35
+ test('a file without frontmatter, or a step with two verbs, is refused with its place', () => {
36
+ assert.throws(() => parseCase('steps: []', 'x.md'), /x\.md: no frontmatter/);
37
+ assert.throws(() => parseCase('---\nid: x\n---\nsteps:\n - { a: 1, b: 2 }\n', 'x.md'), /steps\[0\] must be one step/);
38
+ });
39
+
40
+ test('a path reads into lists, and a star collects from every element', () => {
41
+ const json = { accounts: [{ code: 'usd' }, { code: 'eur' }], user: { id: 7 } };
42
+ assert.equal(dig(json, 'accounts.1.code'), 'eur');
43
+ assert.deepEqual(dig(json, 'accounts.*.code'), ['usd', 'eur']);
44
+ assert.equal(dig(json, 'user.name.first'), undefined);
45
+ });
46
+
47
+ test('a reference alone keeps its type; inside text it is text; a missing one says which', () => {
48
+ const scope = { u: { user_id: 7, name: 'Ana' } };
49
+ assert.deepEqual(interpolate({ id: '{{u.user_id}}', hi: 'hi {{u.name}}', n: 3 }, scope), { id: 7, hi: 'hi Ana', n: 3 });
50
+ assert.match(interpolate('x{{random.letters}}', scope), /^x[a-z]{6}$/);
51
+ assert.throws(() => interpolate('{{v.user_id}}', scope), /no alias 'v'/);
52
+ assert.throws(() => interpolate('{{u.phone}}', scope), /\{\{u\.phone\}\} is empty/);
53
+ });
54
+
55
+ test('matchers compare numbers as numbers and lists by any element', () => {
56
+ assert.equal(matches('25.0', { equals: 25 }), true);
57
+ assert.equal(matches('abc', { equals: 'abc' }), true);
58
+ assert.equal(matches(['usd', 'bridge_usd'], { includes: 'bridge' }), true);
59
+ assert.equal(matches(9, { gte: 10 }), false);
60
+ assert.equal(matches(null, { absent: true }), true);
61
+ assert.equal(matches('AUTORIZADO', { matches: '^AUT' }), true);
62
+ assert.deepEqual(matcherOf({ path: 'a', equals: 1, user: 'u' }), { equals: 1 });
63
+ assert.equal(matcherOf({ path: 'a' }), undefined);
64
+ });
65
+
66
+ test('a case with a matrix runs once per combination, named after it', () => {
67
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-matrix-')), 'm.md');
68
+ fs.writeFileSync(file, '---\nid: m\nmatrix: { locale: [en, fr], login: [phone] }\n---\nsteps:\n - create_user: { locale: "{{matrix.locale}}" }\n');
69
+ const cases = readCases(file);
70
+ assert.deepEqual(
71
+ cases.map((c) => c.id),
72
+ ['m[locale=en,login=phone]', 'm[locale=fr,login=phone]'],
73
+ );
74
+ assert.deepEqual(cases[1]!.setup, []);
75
+ assert.deepEqual(cases[1]!.steps[0]!.value, { locale: 'fr' });
76
+ });
@@ -0,0 +1,230 @@
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
+
7
+ import { driveCommands } from '../../commands/drive.js';
8
+ import { osCommands } from '../../commands/os.js';
9
+ import type { Device } from '../../device/driver.js';
10
+ import { type Base, find, register } from '../../registry.js';
11
+ import { Fixtures } from '../fixtures.js';
12
+ import { parseCase } from '../format.js';
13
+ import { argvFor, newSince, runCase } from '../runner.js';
14
+ import { FIXTURES } from '../templates.js';
15
+
16
+ register([...driveCommands, ...osCommands]);
17
+
18
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-cases-'));
19
+ fs.writeFileSync(path.join(root, 'fixtures.mjs'), FIXTURES);
20
+ const fixtures = new Fixtures({ command: 'node fixtures.mjs' }, root);
21
+ after(() => fixtures.close());
22
+
23
+ type Call = { method: string; params: unknown };
24
+
25
+ // An app with one button that, pressed, shows the points the fixtures hold.
26
+ const fakeApp = () => {
27
+ const calls: Call[] = [];
28
+ let shown = 'Hello';
29
+ const screen = () => ({
30
+ route: 'Profile.Main',
31
+ path: ['Profile.Main'],
32
+ commit: 1,
33
+ busy: false,
34
+ window: { w: 390, h: 844 },
35
+ costMs: 1,
36
+ elements: [
37
+ { i: 0, kind: 'button', testId: 'refresh', enabled: true, depth: 0, frame: { x: 0, y: 0, w: 100, h: 40 } },
38
+ { i: 1, kind: 'text', text: shown, enabled: true, depth: 0, frame: { x: 0, y: 50, w: 100, h: 20 } },
39
+ ],
40
+ });
41
+ const server = {
42
+ connected: true,
43
+ call: async (method: string, params: { testId?: string }) => {
44
+ calls.push({ method, params });
45
+ if (method === 'find') return screen().elements.find((e) => e.testId === params.testId) ?? null;
46
+ return null;
47
+ },
48
+ };
49
+ const device = {
50
+ server,
51
+ screen: async () => screen(),
52
+ trace: async () => ({ last: 0, events: [{ seq: 1, t: 0, kind: 'request', method: 'GET', url: 'http://x/api/points', status: 200 }] }),
53
+ press: async (params: unknown) => {
54
+ calls.push({ method: 'press', params });
55
+ shown = '25 points';
56
+ return { x: 0, y: 0, mode: 'native' };
57
+ },
58
+ waitFor: async () => ({ waitedMs: 0 }),
59
+ idle: async () => ({ settled: true }),
60
+ command: async (name: string) => (name === 'events' ? [{ name: 'points_viewed', properties: { points: 25 } }] : {}),
61
+ } as unknown as Device;
62
+ const base: Base = {
63
+ root,
64
+ config: {},
65
+ attached: true,
66
+ err: () => undefined,
67
+ graph: () => {
68
+ throw new Error('no graph');
69
+ },
70
+ device: async () => device,
71
+ };
72
+ return { base, calls };
73
+ };
74
+
75
+ const config = { refs: { user: 'user_id' }, inspect: 'inspect' };
76
+
77
+ test('a case prepares the backend, drives the app and reads both', async () => {
78
+ const c = parseCase(
79
+ `---
80
+ id: gift
81
+ ---
82
+ setup:
83
+ - create_user: { as: u, name: "Ana" }
84
+ steps:
85
+ - app.see: { text: "Hello" }
86
+ - give_points: { user: u, points: 25 }
87
+ - app.press: "#refresh"
88
+ - app.see: { text: "25 points", request: "GET /api/points", event: points_viewed, props: { points: 25 } }
89
+ - app.see: { text: "Hello", absent: true }
90
+ - assert: { user: u, path: points, equals: 25 }
91
+ - assert: { path: name, equals: "Ana" }
92
+ `,
93
+ 'gift.md',
94
+ );
95
+ const { base, calls } = fakeApp();
96
+ const report = await runCase(c, { base, config, fixtures });
97
+ assert.deepEqual(
98
+ report.steps.map((s) => [s.status, s.error]),
99
+ Array(8).fill(['passed', undefined]),
100
+ );
101
+ assert.equal(calls.filter((x) => x.method === 'press').length, 1);
102
+ });
103
+
104
+ test('the first step that fails stops the case and leaves the evidence', async () => {
105
+ const c = parseCase('---\nid: red\n---\nsteps:\n - app.see: { text: "Goodbye", timeoutMs: 200 }\n - app.press: "#refresh"\n', 'red.md');
106
+ const { base, calls } = fakeApp();
107
+ const report = await runCase(c, { base, config, fixtures });
108
+ assert.equal(report.result, 'failed');
109
+ assert.deepEqual(
110
+ report.steps.map((s) => s.status),
111
+ ['failed', 'skipped'],
112
+ );
113
+ assert.match(report.steps[0]!.error!, /not on screen: "Goodbye"/);
114
+ assert.match(report.evidence!.screen!, /button #refresh/);
115
+ assert.equal(calls.filter((x) => x.method === 'press').length, 0);
116
+ });
117
+
118
+ test('an optional press of something that is not there is skipped, not failed', async () => {
119
+ const c = parseCase('---\nid: opt\n---\nsteps:\n - app.press: { target: "#dismiss", optional: true }\n', 'opt.md');
120
+ const { base } = fakeApp();
121
+ const device = await base.device();
122
+ (device as unknown as { waitFor: () => Promise<never> }).waitFor = async () => {
123
+ throw new Error('Timed out');
124
+ };
125
+ const report = await runCase(c, { base, config, fixtures });
126
+ assert.deepEqual(
127
+ report.steps.map((s) => s.status),
128
+ ['skipped'],
129
+ );
130
+ });
131
+
132
+ test('a macro is the steps a project gave a name to, with what the case passed', async () => {
133
+ const c = parseCase(
134
+ '---\nid: m\n---\nsetup:\n - create_user: { as: u }\nsteps:\n - app.enter: { as: u, then: "#refresh" }\n - app.enter: { as: u }\n',
135
+ 'm.md',
136
+ );
137
+ const { base, calls } = fakeApp();
138
+ const macros = {
139
+ 'app.enter': [{ assert: { user_id: '{{param.as.user_id}}', path: 'user_id', matches: '^u_' } }, { when: 'then', do: { 'app.press': '{{param.then}}' } }],
140
+ };
141
+ const report = await runCase(c, { base, config: { ...config, macros }, fixtures });
142
+ assert.equal(report.result, 'passed', JSON.stringify(report.steps));
143
+ assert.equal(calls.filter((x) => x.method === 'press').length, 1);
144
+ });
145
+
146
+ test("a step that is neither the app's nor a declared fixture says so", async () => {
147
+ const c = parseCase('---\nid: f\n---\nsteps:\n - teleport: { to: "moon" }\n', 'f.md');
148
+ const { base } = fakeApp();
149
+ const report = await runCase(c, { base, config, fixtures });
150
+ assert.match(report.steps[0]!.error!, /no fixture teleport/);
151
+ });
152
+
153
+ test('a step becomes the argv of the command it names', () => {
154
+ assert.deepEqual(argvFor(find('type')!, { text: 'ana', target: '#search', submit: true }), ['type', 'ana', '#search', '--submit']);
155
+ assert.deepEqual(argvFor(find('wait')!, { target: '#row-25', scroll: true, timeoutMs: 9000 }), ['wait', '#row-25', '--scroll', '--timeout', '9000']);
156
+ assert.deepEqual(argvFor(find('press')!, { target: '#like', within: 'hi from Ana' }), ['press', '#like', '--in', 'hi from Ana']);
157
+ assert.deepEqual(argvFor(find('mock')!, { request: 'POST /pay', lost: true, times: 1 }), ['mock', 'POST /pay', '--lost', '--times', '1']);
158
+ assert.deepEqual(argvFor(find('swipe')!, { from: '10,20', to: '10,300', durationMs: 300 }), [
159
+ 'swipe',
160
+ '--from',
161
+ '10,20',
162
+ '--to',
163
+ '10,300',
164
+ '--duration',
165
+ '300',
166
+ ]);
167
+ assert.deepEqual(argvFor(find('mock')!, 'clear'), ['mock', '--clear']);
168
+ assert.throws(() => argvFor(find('press')!, { target: '#x', sideways: true }), /press has no sideways/);
169
+ });
170
+
171
+ test('what a buffer gained is found whichever end it grows from', () => {
172
+ const a = { name: 'a' };
173
+ const b = { name: 'b' };
174
+ const c = { name: 'c' };
175
+ assert.deepEqual(newSince([a], [a, b, c]), [b, c]);
176
+ assert.deepEqual(newSince([a], [c, b, a]), [b, c]);
177
+ assert.deepEqual(newSince([a, b], [a, b]), []);
178
+ assert.deepEqual(newSince([a, b], [c]), [c]);
179
+ });
180
+
181
+ test('a run that asks for events keeps each with the step that caused it', async () => {
182
+ const c = parseCase('---\nid: ev\n---\nsteps:\n - app.see: { text: "Hello" }\n - app.press: "#refresh"\n', 'ev.md');
183
+ const { base } = fakeApp();
184
+ const device = await base.device();
185
+ let buffer: object[] = [];
186
+ (device as unknown as { command: (n: string) => Promise<unknown> }).command = async () => buffer;
187
+ const press = device.press.bind(device);
188
+ (device as unknown as { press: typeof device.press }).press = async (p) => {
189
+ buffer = [{ name: 'refreshed', properties: { by: 'button' } }, ...buffer];
190
+ return press(p);
191
+ };
192
+ const report = await runCase(c, { base, config, fixtures, events: true });
193
+ assert.deepEqual(report.events, [{ name: 'refreshed', props: { by: 'button' }, step: 2, route: 'Profile.Main' }]);
194
+ });
195
+
196
+ test('what the app puts in the way of a step is dealt with before it, unless the step is about it', async () => {
197
+ const { base, calls } = fakeApp();
198
+ const interruptions = [{ see: '#refresh', unless: 'refresh', do: [{ 'app.press': '#refresh' }] }];
199
+ const c = parseCase('---\nid: t\n---\nsteps:\n - app.see: { testId: refresh }\n - app.see: { text: "25 points" }\n', 't.md');
200
+ const report = await runCase(c, { base, config: { ...config, interruptions }, fixtures });
201
+ assert.equal(report.result, 'passed');
202
+ assert.equal(calls.filter((call) => call.method === 'press').length > 0, true);
203
+ });
204
+
205
+ test('a dry run names the step nobody answers and the argument a command does not take', async () => {
206
+ const { base } = fakeApp();
207
+ const c = parseCase('---\nid: t\n---\nsteps:\n - app.teleport: somewhere\n - app.clock: { sideways: 1 }\n - app.clock: { advanceMs: 1000 }\n', 't.md');
208
+ const report = await runCase(c, { base, config, fixtures, dry: true });
209
+ assert.equal(report.result, 'failed');
210
+ assert.deepEqual(
211
+ report.steps.map((s) => s.status),
212
+ ['failed', 'failed', 'passed'],
213
+ );
214
+ });
215
+
216
+ test('a step kept to a device is skipped on a host, and one kept to the host runs there', async () => {
217
+ const { base, calls } = fakeApp();
218
+ const c = parseCase(
219
+ '---\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',
220
+ 't.md',
221
+ );
222
+ assert.equal(c.steps[0]!.on, 'device');
223
+ const report = await runCase(c, { base, config, fixtures });
224
+ assert.equal(report.result, 'passed', JSON.stringify(report.steps));
225
+ assert.deepEqual(
226
+ report.steps.map((s) => s.status),
227
+ ['skipped', 'passed', 'passed'],
228
+ );
229
+ assert.equal(calls.filter((call) => call.method === 'press').length, 1);
230
+ });
@@ -0,0 +1,104 @@
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 { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
13
+ import path from 'node:path';
14
+ import readline from 'node:readline';
15
+
16
+ export type FixturesConfig = { command: string; cwd?: string; env?: Record<string, string>; timeoutMs?: number };
17
+
18
+ export type Answer = Record<string, unknown>;
19
+
20
+ const envOf = (params: Record<string, unknown>): Record<string, string> =>
21
+ Object.fromEntries(
22
+ Object.entries(params)
23
+ .filter(([, v]) => v !== undefined && v !== null)
24
+ .map(([k, v]) => [k.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(), typeof v === 'object' ? JSON.stringify(v) : String(v)]),
25
+ );
26
+
27
+ export class Fixtures {
28
+ #config: FixturesConfig;
29
+ #root: string;
30
+ #child: ChildProcessWithoutNullStreams | null = null;
31
+ #lines: string[] = [];
32
+ #waiting: ((line: string | null) => void) | null = null;
33
+ #stderr = '';
34
+
35
+ constructor(config: FixturesConfig, root: string) {
36
+ this.#config = config;
37
+ this.#root = root;
38
+ }
39
+
40
+ #start(): ChildProcessWithoutNullStreams {
41
+ if (this.#child) return this.#child;
42
+ const child = spawn(this.#config.command, {
43
+ shell: true,
44
+ // Its own process group, so closing takes the shell and what it started.
45
+ detached: true,
46
+ cwd: path.resolve(this.#root, this.#config.cwd ?? '.'),
47
+ env: { ...process.env, ...this.#config.env },
48
+ });
49
+ child.stderr.on('data', (chunk: Buffer) => {
50
+ this.#stderr = (this.#stderr + chunk.toString()).slice(-4000);
51
+ });
52
+ const reader = readline.createInterface({ input: child.stdout });
53
+ reader.on('line', (line) => {
54
+ if (!line.trim().startsWith('{')) return;
55
+ if (this.#waiting) {
56
+ const resolve = this.#waiting;
57
+ this.#waiting = null;
58
+ resolve(line);
59
+ } else this.#lines.push(line);
60
+ });
61
+ child.on('close', () => {
62
+ this.#child = null;
63
+ this.#waiting?.(null);
64
+ this.#waiting = null;
65
+ });
66
+ this.#child = child;
67
+ return child;
68
+ }
69
+
70
+ async call(command: string, params: Record<string, unknown>): Promise<Answer> {
71
+ const child = this.#start();
72
+ child.stdin.write(`${JSON.stringify({ command, params, env: envOf(params) })}\n`);
73
+ const timeoutMs = this.#config.timeoutMs ?? 120_000;
74
+ const line = await new Promise<string | null>((resolve, reject) => {
75
+ const ready = this.#lines.shift();
76
+ if (ready !== undefined) return resolve(ready);
77
+ const timer = setTimeout(() => {
78
+ this.#waiting = null;
79
+ reject(new Error(`${command}: the fixtures command did not answer within ${timeoutMs} ms`));
80
+ }, timeoutMs);
81
+ this.#waiting = (answer) => {
82
+ clearTimeout(timer);
83
+ resolve(answer);
84
+ };
85
+ });
86
+ if (line === null)
87
+ throw new Error(`${command}: the fixtures command ended${this.#stderr ? `: ${this.#stderr.trim().split('\n').slice(-3).join(' · ')}` : ''}`);
88
+ const answer = JSON.parse(line) as Answer;
89
+ if (answer.ok === false) throw new Error(`${command}: ${String(answer.error ?? answer.message ?? 'ok=false')}`);
90
+ return answer;
91
+ }
92
+
93
+ close(): void {
94
+ const child = this.#child;
95
+ this.#child = null;
96
+ if (!child) return;
97
+ child.stdin.end();
98
+ try {
99
+ if (child.pid) process.kill(-child.pid, 'SIGTERM');
100
+ } catch {
101
+ // already gone
102
+ }
103
+ }
104
+ }