lynkeus 0.2.0 → 0.3.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 (127) 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 +80 -3
  14. package/lib/cases/runner.js +387 -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 +27 -6
  20. package/lib/commands/cases.js +360 -37
  21. package/lib/commands/check.d.ts +6 -0
  22. package/lib/commands/check.js +8 -3
  23. package/lib/commands/drive.js +32 -8
  24. package/lib/commands/headless.js +12 -2
  25. package/lib/commands/init.d.ts +6 -0
  26. package/lib/commands/init.js +78 -21
  27. package/lib/commands/report.d.ts +97 -0
  28. package/lib/commands/report.js +228 -0
  29. package/lib/config.d.ts +10 -1
  30. package/lib/device/agentVersion.d.ts +7 -0
  31. package/lib/device/agentVersion.js +12 -0
  32. package/lib/device/devices.d.ts +5 -0
  33. package/lib/device/devices.js +27 -0
  34. package/lib/flow/runner.js +33 -1
  35. package/lib/flow/types.d.ts +2 -1
  36. package/lib/knowledge/contrast.d.ts +45 -0
  37. package/lib/knowledge/contrast.js +114 -0
  38. package/lib/knowledge/errors.js +1 -1
  39. package/lib/knowledge/lint.d.ts +2 -0
  40. package/lib/knowledge/lint.js +13 -3
  41. package/lib/registry.d.ts +5 -0
  42. package/lib/targets.d.ts +10 -1
  43. package/lib/targets.js +16 -3
  44. package/package.json +10 -6
  45. package/src/cases/ahead.ts +111 -0
  46. package/src/cases/fixtures.ts +37 -0
  47. package/src/cases/flakes.ts +67 -0
  48. package/src/cases/format.ts +31 -6
  49. package/src/cases/portClaim.ts +52 -0
  50. package/src/cases/runfile.ts +119 -0
  51. package/src/cases/runner.ts +446 -46
  52. package/src/cases/templates.ts +14 -4
  53. package/src/cases/traces.ts +298 -0
  54. package/src/cli.ts +28 -6
  55. package/src/commands/cases.ts +375 -37
  56. package/src/commands/check.ts +11 -4
  57. package/src/commands/drive.ts +31 -8
  58. package/src/commands/headless.ts +12 -2
  59. package/src/commands/init.ts +84 -20
  60. package/src/commands/report.ts +272 -0
  61. package/src/config.ts +6 -2
  62. package/src/device/agentVersion.ts +15 -0
  63. package/src/device/devices.ts +28 -0
  64. package/src/flow/runner.ts +35 -1
  65. package/src/flow/types.ts +2 -2
  66. package/src/knowledge/contrast.ts +135 -0
  67. package/src/knowledge/errors.ts +1 -1
  68. package/src/knowledge/lint.ts +15 -4
  69. package/src/registry.ts +2 -0
  70. package/src/targets.ts +17 -3
  71. package/lib/__tests__/args.test.d.ts +0 -1
  72. package/lib/__tests__/args.test.js +0 -69
  73. package/lib/__tests__/check.test.d.ts +0 -1
  74. package/lib/__tests__/check.test.js +0 -60
  75. package/lib/__tests__/init.test.d.ts +0 -1
  76. package/lib/__tests__/init.test.js +0 -42
  77. package/lib/__tests__/record.test.d.ts +0 -1
  78. package/lib/__tests__/record.test.js +0 -44
  79. package/lib/__tests__/snapshot.test.d.ts +0 -1
  80. package/lib/__tests__/snapshot.test.js +0 -47
  81. package/lib/__tests__/targets.test.d.ts +0 -1
  82. package/lib/__tests__/targets.test.js +0 -127
  83. package/lib/cases/__tests__/format.test.d.ts +0 -1
  84. package/lib/cases/__tests__/format.test.js +0 -61
  85. package/lib/cases/__tests__/runner.test.d.ts +0 -1
  86. package/lib/cases/__tests__/runner.test.js +0 -188
  87. package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
  88. package/lib/knowledge/__tests__/edges.test.js +0 -25
  89. package/lib/knowledge/__tests__/events.test.d.ts +0 -1
  90. package/lib/knowledge/__tests__/events.test.js +0 -39
  91. package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
  92. package/lib/knowledge/__tests__/extract.test.js +0 -69
  93. package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
  94. package/lib/knowledge/__tests__/frontier.test.js +0 -57
  95. package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
  96. package/lib/knowledge/__tests__/funnels.test.js +0 -40
  97. package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
  98. package/lib/knowledge/__tests__/lint.test.js +0 -53
  99. package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
  100. package/lib/knowledge/__tests__/perf.test.js +0 -34
  101. package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
  102. package/lib/knowledge/__tests__/plan.test.js +0 -35
  103. package/lib/knowledge/__tests__/query.test.d.ts +0 -1
  104. package/lib/knowledge/__tests__/query.test.js +0 -55
  105. package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
  106. package/lib/knowledge/__tests__/screens.test.js +0 -30
  107. package/lib/knowledge/__tests__/why.test.d.ts +0 -1
  108. package/lib/knowledge/__tests__/why.test.js +0 -28
  109. package/src/__tests__/args.test.ts +0 -82
  110. package/src/__tests__/check.test.ts +0 -71
  111. package/src/__tests__/init.test.ts +0 -52
  112. package/src/__tests__/record.test.ts +0 -49
  113. package/src/__tests__/snapshot.test.ts +0 -51
  114. package/src/__tests__/targets.test.ts +0 -132
  115. package/src/cases/__tests__/format.test.ts +0 -76
  116. package/src/cases/__tests__/runner.test.ts +0 -230
  117. package/src/knowledge/__tests__/edges.test.ts +0 -34
  118. package/src/knowledge/__tests__/events.test.ts +0 -45
  119. package/src/knowledge/__tests__/extract.test.ts +0 -88
  120. package/src/knowledge/__tests__/frontier.test.ts +0 -77
  121. package/src/knowledge/__tests__/funnels.test.ts +0 -58
  122. package/src/knowledge/__tests__/lint.test.ts +0 -66
  123. package/src/knowledge/__tests__/perf.test.ts +0 -41
  124. package/src/knowledge/__tests__/plan.test.ts +0 -52
  125. package/src/knowledge/__tests__/query.test.ts +0 -74
  126. package/src/knowledge/__tests__/screens.test.ts +0 -37
  127. package/src/knowledge/__tests__/why.test.ts +0 -40
@@ -0,0 +1,114 @@
1
+ const AA_NORMAL = 4.5;
2
+ const AA_LARGE = 3;
3
+ // A text box this uniform has a flat background; under it the text sits on a picture or a gradient.
4
+ const PLAIN_SHARE = 0.5;
5
+ // Enough pixels of one color to be glyphs and not the fringe where a glyph meets its background.
6
+ const LEAST_SHARE = 0.004;
7
+ const LEAST_PIXELS = 6;
8
+ const channel = (value) => {
9
+ const v = value / 255;
10
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
11
+ };
12
+ /** WCAG's relative luminance of an sRGB color. */
13
+ export const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
14
+ /** WCAG's contrast ratio, from 1 (the same color) to 21 (black on white). */
15
+ export const contrastRatio = (a, b) => {
16
+ const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
17
+ return (light + 0.05) / (dark + 0.05);
18
+ };
19
+ export const hex = (rgb) => `#${rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`;
20
+ /**
21
+ * The contrast of the text inside a rectangle of the picture, in pixels. The background is the color most
22
+ * of the rectangle has; the text is the color, among those with enough pixels to be glyphs, that stands
23
+ * furthest from it. Anything else in the box (an icon beside a label) can only raise the figure, so a low one
24
+ * is a finding and a high one is not a certificate.
25
+ */
26
+ export const measureContrast = (picture, box) => {
27
+ const left = Math.max(0, Math.floor(box.x));
28
+ const top = Math.max(0, Math.floor(box.y));
29
+ const right = Math.min(picture.width, Math.ceil(box.x + box.w));
30
+ const bottom = Math.min(picture.height, Math.ceil(box.y + box.h));
31
+ if (right - left < 2 || bottom - top < 2)
32
+ return undefined;
33
+ // Colors are gathered 8 levels to a bucket: antialiasing and compression never give one flat value.
34
+ const buckets = new Map();
35
+ let total = 0;
36
+ for (let y = top; y < bottom; y++) {
37
+ for (let x = left; x < right; x++) {
38
+ const at = (y * picture.width + x) * 4;
39
+ const r = picture.data[at];
40
+ const g = picture.data[at + 1];
41
+ const b = picture.data[at + 2];
42
+ const key = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
43
+ const bucket = buckets.get(key);
44
+ if (bucket) {
45
+ bucket.count++;
46
+ bucket.r += r;
47
+ bucket.g += g;
48
+ bucket.b += b;
49
+ }
50
+ else {
51
+ buckets.set(key, { count: 1, r, g, b });
52
+ }
53
+ total++;
54
+ }
55
+ }
56
+ const colors = [...buckets.values()].map((bucket) => ({
57
+ count: bucket.count,
58
+ rgb: [bucket.r / bucket.count, bucket.g / bucket.count, bucket.b / bucket.count],
59
+ }));
60
+ const background = colors.reduce((most, color) => (color.count > most.count ? color : most));
61
+ const least = Math.max(LEAST_PIXELS, total * LEAST_SHARE);
62
+ let best;
63
+ for (const color of colors) {
64
+ if (color === background || color.count < least)
65
+ continue;
66
+ const ratio = contrastRatio(color.rgb, background.rgb);
67
+ if (!best || ratio > best.ratio)
68
+ best = { ratio, rgb: color.rgb };
69
+ }
70
+ // Nothing but background: an empty field, or text the same color as what is behind it.
71
+ if (!best || best.ratio < 1.05)
72
+ return undefined;
73
+ return { ratio: best.ratio, text: best.rgb, background: background.rgb, plain: background.count / total >= PLAIN_SHARE };
74
+ };
75
+ const isLarge = (font) => !!font && (font.size >= 18 || (font.size >= 14 && font.bold === true));
76
+ /** WCAG leaves out what cannot be used (a disabled control) and what is not there to be read. */
77
+ const judged = (e) => !e.hidden &&
78
+ !e.covered &&
79
+ !e.offscreen &&
80
+ e.enabled !== false &&
81
+ (e.kind === 'text' || e.kind === 'button' || e.kind === 'input') &&
82
+ (e.text ?? e.value ?? e.placeholder) !== undefined;
83
+ /** A finding for every text on the screen whose contrast falls short, most serious first. */
84
+ export const lintContrast = (screen, picture, options = {}) => {
85
+ const window = screen.window;
86
+ if (!window || window.w <= 0)
87
+ return [];
88
+ const scale = picture.width / window.w;
89
+ const out = [];
90
+ for (const e of screen.elements ?? []) {
91
+ if (!judged(e) || !e.frame || e.frame.w <= 0 || e.frame.h <= 0)
92
+ continue;
93
+ if (e.testId && options.ignore?.some((pattern) => pattern.test(e.testId)))
94
+ continue;
95
+ const measured = measureContrast(picture, { x: e.frame.x * scale, y: e.frame.y * scale, w: e.frame.w * scale, h: e.frame.h * scale });
96
+ if (!measured)
97
+ continue;
98
+ const large = isLarge(e.font);
99
+ const needed = large ? (options.large ?? AA_LARGE) : (options.normal ?? AA_NORMAL);
100
+ if (measured.ratio >= needed)
101
+ continue;
102
+ const said = (e.text ?? e.value ?? e.placeholder ?? '').slice(0, 40);
103
+ out.push({
104
+ rule: 'low-contrast',
105
+ // Over a picture the background is a guess, so it is said and left to a person.
106
+ severity: measured.plain ? 'warn' : 'info',
107
+ element: e.i,
108
+ // The text, not the ratio: a baseline knows the finding again when the measurement moves.
109
+ subject: said,
110
+ message: `"${said}" ${measured.ratio.toFixed(1)}:1 (${hex(measured.text)} on ${hex(measured.background)}${measured.plain ? '' : ', over a varied background'}), needs ${needed}:1${large ? ' for large text' : ''}`,
111
+ });
112
+ }
113
+ return out;
114
+ };
@@ -27,7 +27,7 @@ const RULES = [
27
27
  {
28
28
  re: /Cannot read propert(?:y|ies) of undefined \(reading '(\w+)'\)/,
29
29
  cause: (m) => `something the app expected to exist is undefined (reading '${m[1]}')`,
30
- fix: () => `look one frame up the stack for the module: a mock returning {} instead of the shape the app reads, or a store not initialised because a service failed earlier.`,
30
+ fix: () => `look one frame up the stack for the module: a mock returning {} instead of the shape the app reads, or a store not initialized because a service failed earlier.`,
31
31
  },
32
32
  {
33
33
  re: /Jest did not exit one second after the test run has completed/,
@@ -1,10 +1,12 @@
1
1
  /** What a person would flag in review, read off one screen: not accessibility certification, the usual suspects. */
2
2
  import type { Screen } from 'lynkeus-protocol';
3
+ /** `subject` is what the finding is about, without what was measured: it is how a baseline knows the finding again. */
3
4
  export type Finding = {
4
5
  rule: string;
5
6
  severity: 'warn' | 'info';
6
7
  element: number;
7
8
  message: string;
9
+ subject?: string;
8
10
  };
9
11
  type LintOptions = {
10
12
  /** What an untranslated key looks like (default `a.b.c` and `snake_case_key`); `[]` turns the rule off. */
@@ -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/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.3.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.3.0",
39
+ "lynkeus-protocol": "^0.3.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
+ };