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
@@ -20,7 +20,7 @@ export const lintScreen = (screen, options = {}) => {
20
20
  for (const e of els) {
21
21
  const named = e.text ?? e.accessibilityLabel;
22
22
  if ((e.kind === 'button' || e.kind === 'switch') && named === undefined && e.testId === undefined) {
23
- out.push({ rule: 'unlabeled-control', severity: 'warn', element: e.i, message: `${e.kind} with no text, label or testId` });
23
+ out.push({ rule: 'unlabeled-control', severity: 'warn', element: e.i, message: `${e.kind} with no text, label or testId`, subject: e.kind });
24
24
  }
25
25
  if ((e.kind === 'button' || e.kind === 'switch') && e.enabled !== false && e.frame) {
26
26
  const { w, h } = e.frame;
@@ -32,11 +32,12 @@ export const lintScreen = (screen, options = {}) => {
32
32
  severity: iconOnly ? 'warn' : 'info',
33
33
  element: e.i,
34
34
  message: `${Math.round(w)}×${Math.round(h)}pt target (< ${minTouch}) ${label(e) ?? ''}`.trim(),
35
+ subject: label(e) ?? e.kind,
35
36
  });
36
37
  }
37
38
  }
38
39
  if (e.kind === 'text' && e.text && looksLikeI18nKey(e.text, options.i18nKeyPatterns ?? DEFAULT_I18N_KEY_PATTERNS)) {
39
- out.push({ rule: 'untranslated', severity: 'warn', element: e.i, message: `"${e.text}" looks like an i18n key, not copy` });
40
+ out.push({ rule: 'untranslated', severity: 'warn', element: e.i, message: `"${e.text}" looks like an i18n key, not copy`, subject: e.text });
40
41
  }
41
42
  if ((e.kind === 'button' || e.kind === 'input' || e.kind === 'switch') && e.covered && e.enabled !== false) {
42
43
  out.push({
@@ -44,6 +45,7 @@ export const lintScreen = (screen, options = {}) => {
44
45
  severity: 'info',
45
46
  element: e.i,
46
47
  message: `${e.kind} ${label(e) ?? ''} is covered; a tap would not reach it`.replace(' ', ' ').trim(),
48
+ subject: label(e) ?? e.kind,
47
49
  });
48
50
  }
49
51
  if (e.frame && win.w > 0 && label(e) !== undefined) {
@@ -54,6 +56,7 @@ export const lintScreen = (screen, options = {}) => {
54
56
  severity: 'info',
55
57
  element: e.i,
56
58
  message: `${label(e)} extends past the ${win.w}×${win.h} window (x${Math.round(x)} y${Math.round(y)} ${Math.round(w)}×${Math.round(h)})`,
59
+ subject: label(e),
57
60
  });
58
61
  }
59
62
  }
@@ -63,6 +66,7 @@ export const lintScreen = (screen, options = {}) => {
63
66
  severity: 'info',
64
67
  element: e.i,
65
68
  message: `"${e.text.slice(0, 30)}" has ${Math.round(e.frame.w)}×${Math.round(e.frame.h)} size`,
69
+ subject: e.text,
66
70
  });
67
71
  }
68
72
  }
@@ -76,7 +80,13 @@ export const lintScreen = (screen, options = {}) => {
76
80
  }
77
81
  for (const [id, at] of byTestId)
78
82
  if (at.length > 1)
79
- out.push({ rule: 'duplicate-testid', severity: 'info', element: at[0], message: `testId "${id}" is on ${at.length} elements (${at.join(', ')})` });
83
+ out.push({
84
+ rule: 'duplicate-testid',
85
+ severity: 'info',
86
+ element: at[0],
87
+ message: `testId "${id}" is on ${at.length} elements (${at.join(', ')})`,
88
+ subject: id,
89
+ });
80
90
  return out;
81
91
  };
82
92
  export const describeFindings = (findings, route) => {
package/lib/mcp/server.js CHANGED
@@ -59,9 +59,22 @@ const argvFor = (c, input) => {
59
59
  }
60
60
  return av;
61
61
  };
62
+ // What a model is told once, when it connects: the order things are done in, so "set lynkeus up" and "write me
63
+ // a test for this" need nothing more from the person asking.
64
+ const INSTRUCTIONS = `lynkeus drives a React Native app (bare or Expo) by what is on its screen, and runs cases written as readable steps.
65
+
66
+ In a project you do not know, call status first: it lists what is set up and, for what is not, the one thing that fixes it.
67
+
68
+ To set lynkeus up: call init (it installs lynkeus-agent, mounts it from the app's entry and makes the project's token; nobody sets a variable). The agent has native code, so the app must then be built again (npx react-native run-ios or run-android; npx expo run:ios or run:android, never Expo Go) with Metro restarted with --reset-cache. Do that with your shell if you have one, or ask the person to. Then status until an app is attached.
69
+
70
+ To write a test: call case_init once if the project has no cases, and read the README.md it writes for the format. Go to the screen with press, type and wait, calling screen to see what is there; prefer testIDs (#id) over text. Write the case as a markdown file in the cases folder, then call case_run with that file. A failed step comes back with the screen it failed on: fix the case or report the app's bug, do not weaken the check.
71
+
72
+ To test without a simulator: call headless_init (it installs what the Node host needs and writes its entry; an Expo Router app works too), then headless_doctor for the native modules still to mock, then case_run with host. The backend the app talks to must be local: set apiUrl in lynkeus.config.json.
73
+
74
+ To share a run: case_run with out, screenshots and push uploads it and prints the report's address. That needs LYNKEUS_REPORTS_KEY in this server's environment.`;
62
75
  export const serve = async (session, options = {}) => {
63
76
  const { version } = createRequire(import.meta.url)('../../package.json');
64
- const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} } });
77
+ const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
65
78
  const served = commands().filter((c) => overMcp(c, !!options.full));
66
79
  const byName = new Map(served.map((c) => [identifier(c.name), c]));
67
80
  // So a model reading the app's screens knows their language before it starts guessing.
@@ -107,5 +120,21 @@ export const serve = async (session, options = {}) => {
107
120
  return { ...text(`✗ ${r.error}${r.why ? `\n\nwhy:\n${r.why}` : ''}`), isError: true };
108
121
  return text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
109
122
  });
110
- await server.connect(new StdioServerTransport());
123
+ // The client going away ends the server: the session holds the driver's port open, and a server left behind
124
+ // keeps the next one (or a case run) from binding it.
125
+ const transport = new StdioServerTransport();
126
+ let stopping = false;
127
+ const stop = () => {
128
+ if (stopping)
129
+ return;
130
+ stopping = true;
131
+ void session
132
+ .stop()
133
+ .catch(() => undefined)
134
+ .finally(() => process.exit(0));
135
+ };
136
+ process.stdin.once('end', stop);
137
+ process.stdin.once('close', stop);
138
+ transport.onclose = stop;
139
+ await server.connect(transport);
111
140
  };
package/lib/registry.d.ts CHANGED
@@ -19,6 +19,11 @@ export type Base = {
19
19
  err: (line: string) => void;
20
20
  /** Inside `lynkeus session` and over MCP: one connection, many commands. */
21
21
  attached: boolean;
22
+ /** The same, but waiting for its app on another port: one per host when several run side by side. */
23
+ forPort?: (port: number) => {
24
+ base: Base;
25
+ stop: () => Promise<void>;
26
+ };
22
27
  };
23
28
  export type Ctx = Base & {
24
29
  args: string[];
package/lib/targets.d.ts CHANGED
@@ -1,13 +1,22 @@
1
1
  import type { Target } from 'lynkeus-protocol';
2
2
  export declare const TARGET_HELP = "#testId | \"text\" | 12 (index) | 100,200";
3
- /** `#testId`, `12` (an index), `100,200` (a point), anything else a text; `#testId[2]` or `text[2]` is the third match. */
3
+ /**
4
+ * `#testId`, `12` (an index), `100,200` (a point), anything else a text; `#testId[2]` or `text[2]` is the third match.
5
+ * A text in quotes is that text and nothing else: `"12"` is not an index, and `"Next"` is not a route.
6
+ */
4
7
  export declare const parseTarget: (raw: string) => Target;
5
8
  /** A target narrowed to the match nearest another (`--in`) and to a position among the matches (`--nth`). */
6
9
  export declare const scopedTarget: (raw: string, flags: {
7
10
  in?: unknown;
8
11
  nth?: unknown;
9
12
  }) => Target;
13
+ /**
14
+ * A word that starts with a capital is a route (`Home.Main`), as React Navigation names them. A router
15
+ * that names routes after files (Expo Router's `index`, `(tabs)/settings`) says so: `route:index`.
16
+ */
10
17
  export declare const isRoute: (raw: string) => boolean;
18
+ /** The route a target names, without the prefix that marked it as one. */
19
+ export declare const routeOf: (raw: string) => string;
11
20
  export type Expected = {
12
21
  testId?: string;
13
22
  text?: string;
package/lib/targets.js CHANGED
@@ -1,6 +1,12 @@
1
1
  export const TARGET_HELP = '#testId | "text" | 12 (index) | 100,200';
2
- /** `#testId`, `12` (an index), `100,200` (a point), anything else a text; `#testId[2]` or `text[2]` is the third match. */
2
+ /**
3
+ * `#testId`, `12` (an index), `100,200` (a point), anything else a text; `#testId[2]` or `text[2]` is the third match.
4
+ * A text in quotes is that text and nothing else: `"12"` is not an index, and `"Next"` is not a route.
5
+ */
3
6
  export const parseTarget = (raw) => {
7
+ const quoted = /^(["'])((?:(?!\1).)*)\1$/s.exec(raw);
8
+ if (quoted)
9
+ return { text: quoted[2] };
4
10
  const nth = /^(.+)\[(\d+)\]$/.exec(raw);
5
11
  if (nth && !/^\d+$/.test(nth[1])) {
6
12
  const base = parseTarget(nth[1]);
@@ -31,13 +37,20 @@ export const scopedTarget = (raw, flags) => {
31
37
  ...(typeof flags.nth === 'number' ? { nth: flags.nth } : {}),
32
38
  };
33
39
  };
34
- export const isRoute = (raw) => /^[A-Z]\w*(\.[A-Z]\w*)*$/.test(raw);
40
+ const ROUTE_PREFIX = 'route:';
41
+ /**
42
+ * A word that starts with a capital is a route (`Home.Main`), as React Navigation names them. A router
43
+ * that names routes after files (Expo Router's `index`, `(tabs)/settings`) says so: `route:index`.
44
+ */
45
+ export const isRoute = (raw) => raw.startsWith(ROUTE_PREFIX) || /^[A-Z]\w*(\.[A-Z]\w*)*$/.test(raw);
46
+ /** The route a target names, without the prefix that marked it as one. */
47
+ export const routeOf = (raw) => (raw.startsWith(ROUTE_PREFIX) ? raw.slice(ROUTE_PREFIX.length) : raw);
35
48
  /** What a command was looking for, as `why` reads it. */
36
49
  export const expectedOf = (raw) => {
37
50
  if (!raw)
38
51
  return undefined;
39
52
  if (isRoute(raw))
40
- return { route: raw };
53
+ return { route: routeOf(raw) };
41
54
  const t = parseTarget(raw);
42
55
  return 'testId' in t ? { testId: t.testId } : 'text' in t ? { text: t.text } : undefined;
43
56
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkeus",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Drive a React Native app running lynkeus-agent from the terminal, a test runner or an MCP client: screens, flows, smoke, crawl, and the app's screen graph.",
5
5
  "type": "module",
6
6
  "bin": "./lib/cli.js",
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "lib",
17
- "src"
17
+ "src",
18
+ "!**/__tests__"
18
19
  ],
19
20
  "scripts": {
20
21
  "build": "tsc -p tsconfig.json",
@@ -25,14 +26,17 @@
25
26
  },
26
27
  "repository": {
27
28
  "type": "git",
28
- "url": "git+https://github.com/jonamischener/lynkeus.git",
29
+ "url": "git+https://github.com/lynkeushq/lynkeus.git",
29
30
  "directory": "packages/cli"
30
31
  },
31
32
  "license": "MIT",
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
32
36
  "dependencies": {
33
37
  "@modelcontextprotocol/sdk": "^1.30.0",
34
- "lynkeus-client": "^0.2.0",
35
- "lynkeus-protocol": "^0.2.0",
38
+ "lynkeus-client": "^0.4.0",
39
+ "lynkeus-protocol": "^0.4.0",
36
40
  "pngjs": "^7.0.0",
37
41
  "ts-morph": "^24.0.0",
38
42
  "yaml": "^2"
@@ -43,5 +47,5 @@
43
47
  "tsx": "^4.19.2",
44
48
  "typescript": "^6.0.3"
45
49
  },
46
- "homepage": "https://github.com/jonamischener/lynkeus#readme"
50
+ "homepage": "https://github.com/lynkeushq/lynkeus#readme"
47
51
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Setups made ahead of their cases. `case prepare` runs, in the order a suite
3
+ * will, the setup of every case that needs only the project's fixtures, and
4
+ * leaves what it produced here, one file a case; `case run` takes the file
5
+ * when its case starts and goes straight to the steps.
6
+ *
7
+ * A case that starts before its setup is ready marks itself started, runs its
8
+ * own setup, and the preparer skips it. Files are taken with a rename, so two
9
+ * processes never get the same one.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ import type { Case } from './format.js';
15
+ import type { Prepared } from './runner.js';
16
+
17
+ // What a backend held a quarter of an hour ago may have moved on (a session, a rate).
18
+ const MAX_AGE_MS = 15 * 60_000;
19
+
20
+ type Kept = { setup: string; at: number; prepared: Prepared };
21
+
22
+ /** Where setups made ahead wait for their cases: files for a preparer in another process, memory in the same one. */
23
+ export type Store = {
24
+ start(c: Case): void;
25
+ started(c: Case): boolean;
26
+ keep(c: Case, prepared: Prepared): void;
27
+ take(c: Case): Prepared | undefined;
28
+ waiting(): number;
29
+ };
30
+
31
+ export class InMemory implements Store {
32
+ readonly #started = new Set<string>();
33
+ readonly #kept = new Map<string, Prepared>();
34
+
35
+ start(c: Case): void {
36
+ this.#started.add(c.id);
37
+ }
38
+
39
+ started(c: Case): boolean {
40
+ return this.#started.has(c.id);
41
+ }
42
+
43
+ keep(c: Case, prepared: Prepared): void {
44
+ if (!this.started(c)) this.#kept.set(c.id, prepared);
45
+ }
46
+
47
+ take(c: Case): Prepared | undefined {
48
+ const prepared = this.#kept.get(c.id);
49
+ this.#kept.delete(c.id);
50
+ return prepared;
51
+ }
52
+
53
+ waiting(): number {
54
+ return this.#kept.size;
55
+ }
56
+ }
57
+
58
+ export class Ahead implements Store {
59
+ constructor(private readonly dir: string) {}
60
+
61
+ #file(c: Case, suffix = '.json') {
62
+ return path.join(this.dir, `${c.id.replace(/[^\w.-]+/g, '_')}${suffix}`);
63
+ }
64
+
65
+ clear(): void {
66
+ fs.rmSync(this.dir, { recursive: true, force: true });
67
+ fs.mkdirSync(this.dir, { recursive: true });
68
+ }
69
+
70
+ start(c: Case): void {
71
+ fs.mkdirSync(this.dir, { recursive: true });
72
+ fs.writeFileSync(this.#file(c, '.started'), '');
73
+ }
74
+
75
+ started(c: Case): boolean {
76
+ return fs.existsSync(this.#file(c, '.started'));
77
+ }
78
+
79
+ keep(c: Case, prepared: Prepared): void {
80
+ // Its case started while this was being made, and ran a setup of its own.
81
+ if (this.started(c)) return;
82
+ const file = this.#file(c);
83
+ const kept: Kept = { setup: JSON.stringify(c.setup), at: Date.now(), prepared };
84
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(kept));
85
+ fs.renameSync(`${file}.tmp`, file);
86
+ }
87
+
88
+ /** Setups made and not yet taken by a case that is still to start. */
89
+ waiting(): number {
90
+ return fs.readdirSync(this.dir).filter((f) => f.endsWith('.json') && !fs.existsSync(path.join(this.dir, f.replace(/\.json$/, '.started')))).length;
91
+ }
92
+
93
+ /** The case's setup if it was made, is recent and is the setup the case has now. */
94
+ take(c: Case): Prepared | undefined {
95
+ const file = this.#file(c);
96
+ const mine = `${file}.${process.pid}`;
97
+ try {
98
+ fs.renameSync(file, mine);
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ try {
103
+ const kept = JSON.parse(fs.readFileSync(mine, 'utf8')) as Kept;
104
+ return kept.setup === JSON.stringify(c.setup) && Date.now() - kept.at < MAX_AGE_MS ? kept.prepared : undefined;
105
+ } catch {
106
+ return undefined;
107
+ } finally {
108
+ fs.rmSync(mine, { force: true });
109
+ }
110
+ }
111
+ }
@@ -8,6 +8,22 @@
8
8
  * — and one line out, the step's answer: any JSON object, `"ok": false` and an
9
9
  * `error` when it failed. `params` is the step as written; `env` is the same
10
10
  * flattened to upper-case strings, for a server that hands them to a script.
11
+ *
12
+ * Before the first case lynkeus asks what the server can do —
13
+ *
14
+ * {"command": "lynkeus.describe", "params": {}, "env": {}}
15
+ *
16
+ * — and a server that knows answers `{"commands": ["create_user", …]}`. With
17
+ * that, a case naming a fixture nobody answers is refused before anything
18
+ * runs. A server that answers `"ok": false`, as one written before this did,
19
+ * is taken to describe nothing, and its cases run unchecked. A server that is
20
+ * running but cannot serve (its database is down) adds `"down": "<why>"`, and
21
+ * no case starts: every one would fail at its first fixture.
22
+ *
23
+ * A connection that makes cases' setups ahead of time (`case prepare`) says so
24
+ * first, with `lynkeus.ahead`, so a server may give its work a lower priority
25
+ * than the cases running meanwhile. A server that does not know it is free to
26
+ * answer anything.
11
27
  */
12
28
  import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
13
29
  import path from 'node:path';
@@ -17,6 +33,14 @@ export type FixturesConfig = { command: string; cwd?: string; env?: Record<strin
17
33
 
18
34
  export type Answer = Record<string, unknown>;
19
35
 
36
+ /** The one command that is lynkeus's own and not the project's. */
37
+ export const DESCRIBE = 'lynkeus.describe';
38
+
39
+ export type Description = { commands: string[]; down?: string };
40
+
41
+ /** lynkeus's second own command: this connection works ahead of the cases. */
42
+ export const AHEAD = 'lynkeus.ahead';
43
+
20
44
  const envOf = (params: Record<string, unknown>): Record<string, string> =>
21
45
  Object.fromEntries(
22
46
  Object.entries(params)
@@ -90,6 +114,19 @@ export class Fixtures {
90
114
  return answer;
91
115
  }
92
116
 
117
+ /** What the server says it answers, or nothing when it does not say. */
118
+ async describe(): Promise<Description | undefined> {
119
+ const answer = await this.call(DESCRIBE, {}).catch(() => undefined);
120
+ const commands = answer?.commands;
121
+ if (!Array.isArray(commands) || !commands.every((c) => typeof c === 'string')) return undefined;
122
+ return typeof answer?.down === 'string' && answer.down ? { commands, down: answer.down } : { commands };
123
+ }
124
+
125
+ /** Says this connection works ahead of the cases being run. Whatever the server answers, it goes on. */
126
+ async ahead(): Promise<void> {
127
+ await this.call(AHEAD, {}).catch(() => undefined);
128
+ }
129
+
93
130
  close(): void {
94
131
  const child = this.#child;
95
132
  this.#child = null;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * How each case has done across runs, kept in one JSON file: the last results,
3
+ * how often it needed its second chance, the step it last died on. A case that
4
+ * passes and fails without anything changing is the one to look at first.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ import type { CaseReport } from './runner.js';
10
+
11
+ const KEPT = 20;
12
+
13
+ export type Outcome = { at: string; result: CaseReport['result']; retried: boolean; ms?: number; step?: string; error?: string };
14
+ export type Ledger = Record<string, { runs: number; failed: number; retried: number; last: Outcome[] }>;
15
+
16
+ export class Flakes {
17
+ private readonly ledger: Ledger;
18
+
19
+ constructor(private readonly file: string) {
20
+ let read: Ledger = {};
21
+ try {
22
+ read = JSON.parse(fs.readFileSync(file, 'utf8')) as Ledger;
23
+ } catch {
24
+ // No ledger yet, or one this cannot read: start a new one.
25
+ }
26
+ this.ledger = read;
27
+ }
28
+
29
+ note(report: CaseReport & { retried?: boolean }, at = new Date()): void {
30
+ const entry = this.ledger[report.id] ?? { runs: 0, failed: 0, retried: 0, last: [] };
31
+ this.ledger[report.id] = entry;
32
+ const broke = report.steps.find((s) => s.status === 'failed');
33
+ entry.runs += 1;
34
+ if (report.result === 'failed') entry.failed += 1;
35
+ if (report.retried) entry.retried += 1;
36
+ entry.last = [
37
+ ...entry.last,
38
+ {
39
+ at: at.toISOString(),
40
+ result: report.result,
41
+ retried: !!report.retried,
42
+ ms: report.ms,
43
+ ...(broke ? { step: broke.step, error: broke.error?.split('\n')[0] } : {}),
44
+ },
45
+ ].slice(-KEPT);
46
+ }
47
+
48
+ /** How long a case took when it last passed, if the ledger has seen it pass. */
49
+ lastMs(id: string): number | undefined {
50
+ return [...(this.ledger[id]?.last ?? [])].reverse().find((o) => o.result === 'passed' && o.ms !== undefined)?.ms;
51
+ }
52
+
53
+ /** The cases among `ids` whose recent runs disagree with each other, worded for a summary. */
54
+ flaky(ids: string[]): string[] {
55
+ return ids.flatMap((id) => {
56
+ const last = this.ledger[id]?.last ?? [];
57
+ const shaky = last.filter((o) => o.result === 'failed' || o.retried).length;
58
+ if (shaky === 0 || shaky === last.length) return [];
59
+ return [`⚠ ${id} is flaky: ${shaky} of its last ${last.length} runs failed or needed a second chance`];
60
+ });
61
+ }
62
+
63
+ save(): void {
64
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
65
+ fs.writeFileSync(this.file, `${JSON.stringify(this.ledger, null, 2)}\n`);
66
+ }
67
+ }
@@ -106,21 +106,46 @@ export const dig = (json: unknown, dotted: string): unknown => {
106
106
  return current;
107
107
  };
108
108
 
109
+ export type Scope = Record<string, unknown>;
110
+
111
+ /** Where a seeded case keeps how far its random values have gone; no alias can be named this. */
112
+ const SEED = '~seed';
113
+
114
+ /**
115
+ * With LYNKEUS_SEED set, a case's `{{random.*}}` values are the same on every run: two runs of it then show
116
+ * the same names, which comparing their screens needs. Each case draws its own, so cases side by side differ.
117
+ */
118
+ export const seeded = (scope: Scope, id: string): void => {
119
+ const seed = process.env.LYNKEUS_SEED;
120
+ if (!seed || scope[SEED] !== undefined) return;
121
+ let hash = 2166136261;
122
+ for (const char of `${seed}\n${id}`) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
123
+ scope[SEED] = hash >>> 0;
124
+ };
125
+
109
126
  const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
110
- const random = (kind: string): string | undefined => {
111
- if (kind === 'letters') return Array.from({ length: 6 }, () => LETTERS[Math.floor(Math.random() * 26)]).join('');
112
- if (kind === 'digits') return Array.from({ length: 6 }, () => Math.floor(Math.random() * 10)).join('');
127
+ const random = (kind: string, scope: Scope): string | undefined => {
128
+ const next = (): number => {
129
+ if (typeof scope[SEED] !== 'number') return Math.random();
130
+ // mulberry32
131
+ const state = (scope[SEED] + 0x6d2b79f5) >>> 0;
132
+ scope[SEED] = state;
133
+ let t = state;
134
+ t = Math.imul(t ^ (t >>> 15), t | 1);
135
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
136
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
137
+ };
138
+ if (kind === 'letters') return Array.from({ length: 6 }, () => LETTERS[Math.floor(next() * 26)]).join('');
139
+ if (kind === 'digits') return Array.from({ length: 6 }, () => Math.floor(next() * 10)).join('');
113
140
  return undefined;
114
141
  };
115
142
 
116
- export type Scope = Record<string, unknown>;
117
-
118
143
  /** `{{alias.path}}` from what earlier steps answered, `{{random.letters}}`, `{{env.NAME}}`. */
119
144
  export const interpolate = <T>(value: T, scope: Scope): T => {
120
145
  if (typeof value === 'string') {
121
146
  const whole = /^\{\{\s*([\w-]+)\.([\w.*-]+)\s*\}\}$/.exec(value);
122
147
  const lookup = (name: string, path: string): unknown => {
123
- if (name === 'random') return random(path);
148
+ if (name === 'random') return random(path, scope);
124
149
  if (name === 'env') return process.env[path];
125
150
  if (!(name in scope)) throw new Error(`no alias '${name}' (add \`as: ${name}\` to the step that creates it)`);
126
151
  return dig(scope[name], path);
@@ -0,0 +1,52 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const alive = (pid: number): boolean => {
6
+ try {
7
+ process.kill(pid, 0);
8
+ return true;
9
+ } catch (error) {
10
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
11
+ }
12
+ };
13
+
14
+ /**
15
+ * A run lets go of its driver port between cases, and the app dials whichever driver is listening: two runs
16
+ * on one port take turns at the app, and each reports steps the other drove. A run claims its ports for as
17
+ * long as it lasts; what it answers is the release, or why another run holds one.
18
+ */
19
+ export const claimPorts = (ports: number[], dir = os.tmpdir()): { release: () => void } | { refused: string } => {
20
+ const held: string[] = [];
21
+ const release = () => {
22
+ for (const file of held) {
23
+ try {
24
+ if (fs.readFileSync(file, 'utf8').trim() === String(process.pid)) fs.unlinkSync(file);
25
+ } catch {
26
+ // Already gone.
27
+ }
28
+ }
29
+ };
30
+ for (const port of ports) {
31
+ const file = path.join(dir, `lynkeus-port-${port}.pid`);
32
+ for (;;) {
33
+ try {
34
+ fs.writeFileSync(file, String(process.pid), { flag: 'wx' });
35
+ held.push(file);
36
+ break;
37
+ } catch (error) {
38
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
39
+ }
40
+ const owner = Number(fs.readFileSync(file, 'utf8').trim());
41
+ if (Number.isInteger(owner) && owner > 0 && owner !== process.pid && alive(owner)) {
42
+ release();
43
+ return {
44
+ refused: `port ${port} is held by another lynkeus case run (pid ${owner}), which is probably still going: stop it, or give this run other --ports`,
45
+ };
46
+ }
47
+ // Left by a run that ended without letting go.
48
+ fs.rmSync(file, { force: true });
49
+ }
50
+ }
51
+ return { release };
52
+ };