lynkeus 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/lib/cases/ahead.d.ts +31 -0
  2. package/lib/cases/ahead.js +90 -0
  3. package/lib/cases/fixtures.d.ts +12 -0
  4. package/lib/cases/fixtures.js +32 -0
  5. package/lib/cases/flakes.d.ts +28 -0
  6. package/lib/cases/flakes.js +61 -0
  7. package/lib/cases/format.d.ts +5 -0
  8. package/lib/cases/format.js +30 -4
  9. package/lib/cases/portClaim.d.ts +10 -0
  10. package/lib/cases/portClaim.js +55 -0
  11. package/lib/cases/runfile.d.ts +24 -0
  12. package/lib/cases/runfile.js +108 -0
  13. package/lib/cases/runner.d.ts +82 -3
  14. package/lib/cases/runner.js +394 -40
  15. package/lib/cases/templates.d.ts +2 -2
  16. package/lib/cases/templates.js +14 -4
  17. package/lib/cases/traces.d.ts +58 -0
  18. package/lib/cases/traces.js +250 -0
  19. package/lib/cli.js +28 -7
  20. package/lib/commands/cases.js +411 -42
  21. package/lib/commands/check.d.ts +6 -0
  22. package/lib/commands/check.js +8 -3
  23. package/lib/commands/doctor.js +8 -2
  24. package/lib/commands/drive.js +32 -8
  25. package/lib/commands/headless.js +132 -8
  26. package/lib/commands/init.d.ts +29 -0
  27. package/lib/commands/init.js +295 -33
  28. package/lib/commands/report.d.ts +97 -0
  29. package/lib/commands/report.js +228 -0
  30. package/lib/config.d.ts +26 -1
  31. package/lib/config.js +30 -0
  32. package/lib/device/agentVersion.d.ts +7 -0
  33. package/lib/device/agentVersion.js +12 -0
  34. package/lib/device/devices.d.ts +5 -0
  35. package/lib/device/devices.js +27 -0
  36. package/lib/device/driver.js +2 -2
  37. package/lib/flow/runner.js +33 -1
  38. package/lib/flow/types.d.ts +2 -1
  39. package/lib/knowledge/contrast.d.ts +45 -0
  40. package/lib/knowledge/contrast.js +114 -0
  41. package/lib/knowledge/errors.js +1 -1
  42. package/lib/knowledge/lint.d.ts +2 -0
  43. package/lib/knowledge/lint.js +13 -3
  44. package/lib/mcp/server.js +31 -2
  45. package/lib/registry.d.ts +5 -0
  46. package/lib/targets.d.ts +10 -1
  47. package/lib/targets.js +16 -3
  48. package/package.json +10 -6
  49. package/src/cases/ahead.ts +111 -0
  50. package/src/cases/fixtures.ts +37 -0
  51. package/src/cases/flakes.ts +67 -0
  52. package/src/cases/format.ts +31 -6
  53. package/src/cases/portClaim.ts +52 -0
  54. package/src/cases/runfile.ts +119 -0
  55. package/src/cases/runner.ts +454 -46
  56. package/src/cases/templates.ts +14 -4
  57. package/src/cases/traces.ts +298 -0
  58. package/src/cli.ts +29 -7
  59. package/src/commands/cases.ts +428 -41
  60. package/src/commands/check.ts +11 -4
  61. package/src/commands/doctor.ts +9 -2
  62. package/src/commands/drive.ts +31 -8
  63. package/src/commands/headless.ts +143 -8
  64. package/src/commands/init.ts +338 -30
  65. package/src/commands/report.ts +272 -0
  66. package/src/config.ts +39 -2
  67. package/src/device/agentVersion.ts +15 -0
  68. package/src/device/devices.ts +28 -0
  69. package/src/device/driver.ts +2 -2
  70. package/src/flow/runner.ts +35 -1
  71. package/src/flow/types.ts +2 -2
  72. package/src/knowledge/contrast.ts +135 -0
  73. package/src/knowledge/errors.ts +1 -1
  74. package/src/knowledge/lint.ts +15 -4
  75. package/src/mcp/server.ts +31 -2
  76. package/src/registry.ts +2 -0
  77. package/src/targets.ts +17 -3
  78. package/lib/__tests__/args.test.d.ts +0 -1
  79. package/lib/__tests__/args.test.js +0 -69
  80. package/lib/__tests__/check.test.d.ts +0 -1
  81. package/lib/__tests__/check.test.js +0 -60
  82. package/lib/__tests__/init.test.d.ts +0 -1
  83. package/lib/__tests__/init.test.js +0 -42
  84. package/lib/__tests__/record.test.d.ts +0 -1
  85. package/lib/__tests__/record.test.js +0 -44
  86. package/lib/__tests__/snapshot.test.d.ts +0 -1
  87. package/lib/__tests__/snapshot.test.js +0 -47
  88. package/lib/__tests__/targets.test.d.ts +0 -1
  89. package/lib/__tests__/targets.test.js +0 -127
  90. package/lib/cases/__tests__/format.test.d.ts +0 -1
  91. package/lib/cases/__tests__/format.test.js +0 -61
  92. package/lib/cases/__tests__/runner.test.d.ts +0 -1
  93. package/lib/cases/__tests__/runner.test.js +0 -188
  94. package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
  95. package/lib/knowledge/__tests__/edges.test.js +0 -25
  96. package/lib/knowledge/__tests__/events.test.d.ts +0 -1
  97. package/lib/knowledge/__tests__/events.test.js +0 -39
  98. package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
  99. package/lib/knowledge/__tests__/extract.test.js +0 -69
  100. package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
  101. package/lib/knowledge/__tests__/frontier.test.js +0 -57
  102. package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
  103. package/lib/knowledge/__tests__/funnels.test.js +0 -40
  104. package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
  105. package/lib/knowledge/__tests__/lint.test.js +0 -53
  106. package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
  107. package/lib/knowledge/__tests__/perf.test.js +0 -34
  108. package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
  109. package/lib/knowledge/__tests__/plan.test.js +0 -35
  110. package/lib/knowledge/__tests__/query.test.d.ts +0 -1
  111. package/lib/knowledge/__tests__/query.test.js +0 -55
  112. package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
  113. package/lib/knowledge/__tests__/screens.test.js +0 -30
  114. package/lib/knowledge/__tests__/why.test.d.ts +0 -1
  115. package/lib/knowledge/__tests__/why.test.js +0 -28
  116. package/src/__tests__/args.test.ts +0 -82
  117. package/src/__tests__/check.test.ts +0 -71
  118. package/src/__tests__/init.test.ts +0 -52
  119. package/src/__tests__/record.test.ts +0 -49
  120. package/src/__tests__/snapshot.test.ts +0 -51
  121. package/src/__tests__/targets.test.ts +0 -132
  122. package/src/cases/__tests__/format.test.ts +0 -76
  123. package/src/cases/__tests__/runner.test.ts +0 -230
  124. package/src/knowledge/__tests__/edges.test.ts +0 -34
  125. package/src/knowledge/__tests__/events.test.ts +0 -45
  126. package/src/knowledge/__tests__/extract.test.ts +0 -88
  127. package/src/knowledge/__tests__/frontier.test.ts +0 -77
  128. package/src/knowledge/__tests__/funnels.test.ts +0 -58
  129. package/src/knowledge/__tests__/lint.test.ts +0 -66
  130. package/src/knowledge/__tests__/perf.test.ts +0 -41
  131. package/src/knowledge/__tests__/plan.test.ts +0 -52
  132. package/src/knowledge/__tests__/query.test.ts +0 -74
  133. package/src/knowledge/__tests__/screens.test.ts +0 -37
  134. package/src/knowledge/__tests__/why.test.ts +0 -40
@@ -1,7 +1,8 @@
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
3
 
4
- export type Finding = { rule: string; severity: 'warn' | 'info'; element: number; message: string };
4
+ /** `subject` is what the finding is about, without what was measured: it is how a baseline knows the finding again. */
5
+ export type Finding = { rule: string; severity: 'warn' | 'info'; element: number; message: string; subject?: string };
5
6
 
6
7
  const DEFAULT_I18N_KEY_PATTERNS = [/^[a-z][a-z0-9]*(\.[a-z0-9]+){2,}$/i, /^[a-z0-9]+(_[a-z0-9]+){2,}$/i];
7
8
 
@@ -31,7 +32,7 @@ export const lintScreen = (screen: Screen, options: LintOptions = {}): Finding[]
31
32
  for (const e of els) {
32
33
  const named = e.text ?? e.accessibilityLabel;
33
34
  if ((e.kind === 'button' || e.kind === 'switch') && named === undefined && e.testId === undefined) {
34
- out.push({ rule: 'unlabeled-control', severity: 'warn', element: e.i, message: `${e.kind} with no text, label or testId` });
35
+ out.push({ rule: 'unlabeled-control', severity: 'warn', element: e.i, message: `${e.kind} with no text, label or testId`, subject: e.kind });
35
36
  }
36
37
  if ((e.kind === 'button' || e.kind === 'switch') && e.enabled !== false && e.frame) {
37
38
  const { w, h } = e.frame;
@@ -43,11 +44,12 @@ export const lintScreen = (screen: Screen, options: LintOptions = {}): Finding[]
43
44
  severity: iconOnly ? 'warn' : 'info',
44
45
  element: e.i,
45
46
  message: `${Math.round(w)}×${Math.round(h)}pt target (< ${minTouch}) ${label(e) ?? ''}`.trim(),
47
+ subject: label(e) ?? e.kind,
46
48
  });
47
49
  }
48
50
  }
49
51
  if (e.kind === 'text' && e.text && looksLikeI18nKey(e.text, options.i18nKeyPatterns ?? DEFAULT_I18N_KEY_PATTERNS)) {
50
- out.push({ rule: 'untranslated', severity: 'warn', element: e.i, message: `"${e.text}" looks like an i18n key, not copy` });
52
+ out.push({ rule: 'untranslated', severity: 'warn', element: e.i, message: `"${e.text}" looks like an i18n key, not copy`, subject: e.text });
51
53
  }
52
54
  if ((e.kind === 'button' || e.kind === 'input' || e.kind === 'switch') && e.covered && e.enabled !== false) {
53
55
  out.push({
@@ -55,6 +57,7 @@ export const lintScreen = (screen: Screen, options: LintOptions = {}): Finding[]
55
57
  severity: 'info',
56
58
  element: e.i,
57
59
  message: `${e.kind} ${label(e) ?? ''} is covered; a tap would not reach it`.replace(' ', ' ').trim(),
60
+ subject: label(e) ?? e.kind,
58
61
  });
59
62
  }
60
63
  if (e.frame && win.w > 0 && label(e) !== undefined) {
@@ -65,6 +68,7 @@ export const lintScreen = (screen: Screen, options: LintOptions = {}): Finding[]
65
68
  severity: 'info',
66
69
  element: e.i,
67
70
  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)})`,
71
+ subject: label(e),
68
72
  });
69
73
  }
70
74
  }
@@ -74,6 +78,7 @@ export const lintScreen = (screen: Screen, options: LintOptions = {}): Finding[]
74
78
  severity: 'info',
75
79
  element: e.i,
76
80
  message: `"${e.text.slice(0, 30)}" has ${Math.round(e.frame.w)}×${Math.round(e.frame.h)} size`,
81
+ subject: e.text,
77
82
  });
78
83
  }
79
84
  }
@@ -86,7 +91,13 @@ export const lintScreen = (screen: Screen, options: LintOptions = {}): Finding[]
86
91
  }
87
92
  for (const [id, at] of byTestId)
88
93
  if (at.length > 1)
89
- out.push({ rule: 'duplicate-testid', severity: 'info', element: at[0]!, message: `testId "${id}" is on ${at.length} elements (${at.join(', ')})` });
94
+ out.push({
95
+ rule: 'duplicate-testid',
96
+ severity: 'info',
97
+ element: at[0]!,
98
+ message: `testId "${id}" is on ${at.length} elements (${at.join(', ')})`,
99
+ subject: id,
100
+ });
90
101
  return out;
91
102
  };
92
103
 
package/src/mcp/server.ts CHANGED
@@ -60,9 +60,23 @@ const argvFor = (c: Command, input: Record<string, unknown>): string[] => {
60
60
  return av;
61
61
  };
62
62
 
63
+ // What a model is told once, when it connects: the order things are done in, so "set lynkeus up" and "write me
64
+ // a test for this" need nothing more from the person asking.
65
+ const INSTRUCTIONS = `lynkeus drives a React Native app (bare or Expo) by what is on its screen, and runs cases written as readable steps.
66
+
67
+ 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.
68
+
69
+ 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.
70
+
71
+ 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.
72
+
73
+ 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.
74
+
75
+ 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.`;
76
+
63
77
  export const serve = async (session: Session, options: { full?: boolean } = {}) => {
64
78
  const { version } = createRequire(import.meta.url)('../../package.json') as { version: string };
65
- const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} } });
79
+ const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
66
80
  const served = commands().filter((c) => overMcp(c, !!options.full));
67
81
  const byName = new Map(served.map((c) => [identifier(c.name), c]));
68
82
  // So a model reading the app's screens knows their language before it starts guessing.
@@ -105,5 +119,20 @@ export const serve = async (session: Session, options: { full?: boolean } = {})
105
119
  if (r.error) return { ...text(`✗ ${r.error}${r.why ? `\n\nwhy:\n${r.why}` : ''}`), isError: true };
106
120
  return text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
107
121
  });
108
- await server.connect(new StdioServerTransport());
122
+ // The client going away ends the server: the session holds the driver's port open, and a server left behind
123
+ // keeps the next one (or a case run) from binding it.
124
+ const transport = new StdioServerTransport();
125
+ let stopping = false;
126
+ const stop = () => {
127
+ if (stopping) return;
128
+ stopping = true;
129
+ void session
130
+ .stop()
131
+ .catch(() => undefined)
132
+ .finally(() => process.exit(0));
133
+ };
134
+ process.stdin.once('end', stop);
135
+ process.stdin.once('close', stop);
136
+ transport.onclose = stop;
137
+ await server.connect(transport);
109
138
  };
package/src/registry.ts CHANGED
@@ -29,6 +29,8 @@ export type Base = {
29
29
  err: (line: string) => void;
30
30
  /** Inside `lynkeus session` and over MCP: one connection, many commands. */
31
31
  attached: boolean;
32
+ /** The same, but waiting for its app on another port: one per host when several run side by side. */
33
+ forPort?: (port: number) => { base: Base; stop: () => Promise<void> };
32
34
  };
33
35
 
34
36
  export type Ctx = Base & { args: string[]; flags: FlagValues; passthrough: string[] };
package/src/targets.ts CHANGED
@@ -2,8 +2,13 @@ import type { Target } from 'lynkeus-protocol';
2
2
 
3
3
  export const TARGET_HELP = '#testId | "text" | 12 (index) | 100,200';
4
4
 
5
- /** `#testId`, `12` (an index), `100,200` (a point), anything else a text; `#testId[2]` or `text[2]` is the third match. */
5
+ /**
6
+ * `#testId`, `12` (an index), `100,200` (a point), anything else a text; `#testId[2]` or `text[2]` is the third match.
7
+ * A text in quotes is that text and nothing else: `"12"` is not an index, and `"Next"` is not a route.
8
+ */
6
9
  export const parseTarget = (raw: string): Target => {
10
+ const quoted = /^(["'])((?:(?!\1).)*)\1$/s.exec(raw);
11
+ if (quoted) return { text: quoted[2]! };
7
12
  const nth = /^(.+)\[(\d+)\]$/.exec(raw);
8
13
  if (nth && !/^\d+$/.test(nth[1]!)) {
9
14
  const base = parseTarget(nth[1]!);
@@ -31,14 +36,23 @@ export const scopedTarget = (raw: string, flags: { in?: unknown; nth?: unknown }
31
36
  };
32
37
  };
33
38
 
34
- export const isRoute = (raw: string) => /^[A-Z]\w*(\.[A-Z]\w*)*$/.test(raw);
39
+ const ROUTE_PREFIX = 'route:';
40
+
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: string) => raw.startsWith(ROUTE_PREFIX) || /^[A-Z]\w*(\.[A-Z]\w*)*$/.test(raw);
46
+
47
+ /** The route a target names, without the prefix that marked it as one. */
48
+ export const routeOf = (raw: string) => (raw.startsWith(ROUTE_PREFIX) ? raw.slice(ROUTE_PREFIX.length) : raw);
35
49
 
36
50
  export type Expected = { testId?: string; text?: string; route?: string };
37
51
 
38
52
  /** What a command was looking for, as `why` reads it. */
39
53
  export const expectedOf = (raw: string | undefined): Expected | undefined => {
40
54
  if (!raw) return undefined;
41
- if (isRoute(raw)) return { route: raw };
55
+ if (isRoute(raw)) return { route: routeOf(raw) };
42
56
  const t = parseTarget(raw);
43
57
  return 'testId' in t ? { testId: t.testId } : 'text' in t ? { text: t.text } : undefined;
44
58
  };
@@ -1 +0,0 @@
1
- export {};
@@ -1,69 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
3
- import { pairs, parseArgs, splitLine, UsageError } from '../args.js';
4
- import { driveCommands } from '../commands/drive.js';
5
- import { headlessCommands } from '../commands/headless.js';
6
- import { mapCommands } from '../commands/map.js';
7
- import { osCommands } from '../commands/os.js';
8
- import { runsCommands } from '../commands/runs.js';
9
- const FLAGS = {
10
- direct: { type: 'boolean', help: '' },
11
- depth: { type: 'number', help: '', default: 4 },
12
- var: { type: 'list', help: '' },
13
- out: { type: 'string', help: '' },
14
- };
15
- test('booleans never eat the next positional; numbers are numbers; lists collect', () => {
16
- const p = parseArgs(['--direct', 'a.ts', 'b.ts', '--depth', '2', '--var', 'x=1', '--var', 'y=2'], [{ name: 'file', help: '', required: true, rest: true }], FLAGS);
17
- assert.deepEqual(p.args, ['a.ts', 'b.ts']);
18
- assert.equal(p.flags.direct, true);
19
- assert.equal(p.flags.depth, 2);
20
- assert.deepEqual(p.flags.var, ['x=1', 'y=2']);
21
- assert.deepEqual(pairs(p.flags.var), { x: '1', y: '2' });
22
- });
23
- test('defaults, --no-flag, --flag=value and passthrough after --', () => {
24
- const p = parseArgs(['--no-direct', '--out=x.json', '--', '--jest-flag'], [], FLAGS);
25
- assert.equal(p.flags.direct, false);
26
- assert.equal(p.flags.depth, 4);
27
- assert.equal(p.flags.out, 'x.json');
28
- assert.deepEqual(p.passthrough, ['--jest-flag']);
29
- });
30
- test('unknown flags, missing values and missing positionals are usage errors that name what exists', () => {
31
- assert.throws(() => parseArgs(['--nope'], [], FLAGS), (e) => e instanceof UsageError && /--direct/.test(e.message));
32
- assert.throws(() => parseArgs(['--depth', '--direct'], [], FLAGS), /--depth needs a value/);
33
- assert.throws(() => parseArgs(['--depth', 'x'], [], FLAGS), /expects a number/);
34
- assert.throws(() => parseArgs([], [{ name: 'target', help: '', required: true }], FLAGS), /missing <target>/);
35
- assert.throws(() => parseArgs(['a', 'b'], [{ name: 'one', help: '' }], FLAGS), /unexpected argument "b"/);
36
- });
37
- test('a session line splits on spaces and keeps quoted text together', () => {
38
- assert.deepEqual(splitLine('press "Comenzar ya" --hold 200'), ['press', 'Comenzar ya', '--hold', '200']);
39
- });
40
- test('every command is named once, documented, and its aliases are unique', () => {
41
- const all = [...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...headlessCommands];
42
- const names = new Set();
43
- for (const c of all) {
44
- assert.ok(c.summary.length > 10, `${c.name} has a summary`);
45
- assert.ok(!names.has(c.name), `${c.name} defined once`);
46
- names.add(c.name);
47
- for (const a of c.aliases ?? []) {
48
- assert.ok(!names.has(a), `${a} unique`);
49
- names.add(a);
50
- }
51
- for (const [flag, spec] of Object.entries(c.flags ?? {}))
52
- assert.ok(spec.help, `${c.name} --${flag} has help`);
53
- for (const p of c.positionals ?? [])
54
- assert.ok(p.help, `${c.name} <${p.name}> has help`);
55
- }
56
- for (const short of ['plan', 'deeplink', 'appstate', 'network', 'clock', 'biometrics', 'scan', 'gallery', 'headless'])
57
- assert.ok(names.has(short), `${short} accepted`);
58
- });
59
- test('MCP serves a small core by default and everything else on request', () => {
60
- const all = [...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...headlessCommands];
61
- const core = all.filter((c) => c.mcp === 'core').map((c) => c.name);
62
- assert.deepEqual(core.sort(), ['call', 'map plan', 'map show', 'os deeplink', 'press', 'screen', 'type', 'wait', 'why']);
63
- });
64
- test('what MCP leaves out of a tool is still a flag of the command', () => {
65
- for (const c of [...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...headlessCommands]) {
66
- for (const flag of c.mcpOmit ?? [])
67
- assert.ok(c.flags?.[flag], `${c.name} omits --${flag}, which it does not have`);
68
- }
69
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,60 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
3
- import { diffGraphs, newFindings } from '../commands/check.js';
4
- import { mapCheckWorkflow } from '../commands/map.js';
5
- import { summarizeRuns } from '../commands/flake.js';
6
- const graph = (screens, files) => ({
7
- app: 'x',
8
- root: '/x',
9
- extractedAt: '',
10
- warnings: [],
11
- screens: Object.fromEntries(Object.entries(screens).map(([route, file]) => [route, { route, component: route, file, declaredIn: 'nav.tsx' }])),
12
- files: Object.fromEntries(Object.entries(files).map(([file, f]) => [
13
- file,
14
- {
15
- selectors: (f.selectors ?? []).map((id) => ({ id, line: 1 })),
16
- navigates: (f.navigates ?? []).map((to) => ({ to, line: 1, confidence: 'static', via: { kind: 'unknown' } })),
17
- imports: [],
18
- },
19
- ])),
20
- });
21
- test('a graph diff names routes, navigations and testIDs that changed', () => {
22
- const base = graph({ A: 'a.tsx', B: 'b.tsx' }, { 'a.tsx': { selectors: ['go-b'], navigates: ['B'] }, 'b.tsx': { selectors: ['submit'] } });
23
- const head = graph({ A: 'a.tsx', B: 'b.tsx', C: 'c.tsx' }, { 'a.tsx': { selectors: ['go-b', 'go-c'], navigates: ['B', 'C'] }, 'b.tsx': {}, 'c.tsx': {} });
24
- const d = diffGraphs(base, head);
25
- assert.deepEqual(d.routesAdded, ['C']);
26
- assert.deepEqual(d.edgesAdded, [{ from: 'A', to: 'C' }]);
27
- assert.deepEqual(d.selectorsAdded, [{ file: 'a.tsx', id: 'go-c' }]);
28
- assert.deepEqual(d.selectorsRemoved, [{ file: 'b.tsx', id: 'submit' }]);
29
- });
30
- test('lint findings already in the baseline are not new', () => {
31
- const f1 = { finding: { rule: 'unlabeled-control', severity: 'warn', element: 3, message: 'button without a label' }, testId: 'x' };
32
- const f2 = { finding: { rule: 'small-target', severity: 'warn', element: 4, message: '20×20' }, testId: 'y' };
33
- const fresh = newFindings({ A: [{ rule: 'unlabeled-control', testId: 'x', message: 'button without a label' }] }, 'A', [f1, f2]);
34
- assert.deepEqual(fresh, [f2]);
35
- });
36
- test('flake runs are summarised by the step and error they died on', () => {
37
- const ok = (ms) => ({ name: 'f', ok: true, totalMs: ms, vars: {}, steps: [{ step: { press: { testId: 'a' } }, ok: true, ms: 1 }] });
38
- const bad = (err) => ({
39
- name: 'f',
40
- ok: false,
41
- totalMs: 900,
42
- vars: {},
43
- steps: [
44
- { step: { press: { testId: 'a' } }, ok: true, ms: 1 },
45
- { step: { waitFor: { route: 'A' } }, ok: false, ms: 1, error: err },
46
- ],
47
- });
48
- const s = summarizeRuns([ok(100), bad('Timed out waiting for A (on Z)'), ok(300), bad('Timed out waiting for A (on Y)')]);
49
- assert.equal(s.passed, 2);
50
- assert.equal(s.failures.length, 1);
51
- assert.equal(s.failures[0].times, 2);
52
- assert.equal(s.failures[0].step, 2);
53
- assert.match(s.failures[0].what, /waitFor/);
54
- });
55
- test('the map check workflow diffs against the pull request base and keeps one comment', () => {
56
- const yml = mapCheckWorkflow(2);
57
- assert.match(yml, /lynkeus map check --base origin\/\$\{\{ github\.base_ref \}\} --depth 2/);
58
- assert.match(yml, /--edit-last/);
59
- assert.match(yml, /pull-requests: write/);
60
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,42 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import fs from 'node:fs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { test } from 'node:test';
6
- import { init } from '../commands/init.js';
7
- import { explainError } from '../knowledge/errors.js';
8
- const tmpApp = () => {
9
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-init-'));
10
- fs.mkdirSync(path.join(root, 'app'));
11
- fs.writeFileSync(path.join(root, 'app', 'App.tsx'), 'export default () => null');
12
- fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'demo' }));
13
- fs.writeFileSync(path.join(root, 'index.js'), "import { AppRegistry } from 'react-native'\nimport App from './app/App'\n\nAppRegistry.registerComponent('demo', () => App)\n");
14
- return root;
15
- };
16
- test('init writes an entry that requires the app and refuses non-local backends', () => {
17
- const root = tmpApp();
18
- const notes = init(root, { entry: 'qa/headless.tsx', apiEnv: 'API_URL', auto: false, force: false });
19
- const entry = fs.readFileSync(path.join(root, 'qa/headless.tsx'), 'utf8');
20
- assert.match(entry, /require\('\.\.\/app\/App'\)/);
21
- assert.match(entry, /process\.env\.API_URL/);
22
- assert.match(entry, /installDefaultMocks/);
23
- assert.ok(notes.some((n) => n.includes('wrote qa/headless.tsx')));
24
- // Second run leaves it alone.
25
- const again = init(root, { entry: 'qa/headless.tsx', apiEnv: 'API_URL', auto: false, force: false });
26
- assert.ok(again.some((n) => n.includes('left as is')));
27
- });
28
- test('init --auto adds the mount after the imports, once', () => {
29
- const root = tmpApp();
30
- init(root, { entry: 'qa/headless.tsx', apiEnv: 'API_URL', auto: true, force: false });
31
- init(root, { entry: 'qa/headless.tsx', apiEnv: 'API_URL', auto: true, force: false });
32
- const index = fs.readFileSync(path.join(root, 'index.js'), 'utf8');
33
- assert.equal(index.split('lynkeus-agent/auto').length, 2);
34
- assert.ok(index.indexOf('auto(') < index.indexOf('registerComponent'));
35
- assert.ok(index.indexOf("import App from './app/App'") < index.indexOf('lynkeus-agent/auto'));
36
- });
37
- test('the error catalogue names the module behind a TurboModule crash', () => {
38
- const [e] = explainError("Invariant Violation: TurboModuleRegistry.getEnforcing(...): 'RNCWebView' could not be found. Verify that a module by this name is registered");
39
- assert.ok(e);
40
- assert.match(e.cause, /RNCWebView/);
41
- assert.equal(explainError('all good').length, 0);
42
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,44 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
3
- import { simulatorArgv } from '../commands/device.js';
4
- import { draftFlow } from '../commands/record.js';
5
- const ev = (partial, seq) => ({ seq, t: seq * 100, ...partial });
6
- test('a recording becomes presses with what each one caused', () => {
7
- const flow = draftFlow([
8
- ev({ kind: 'route', route: 'First.Screen', path: ['First.Screen'] }, 1),
9
- ev({ kind: 'touch', x: 10, y: 20, testId: 'primary-button', route: 'First.Screen' }, 2),
10
- ev({ kind: 'request', method: 'GET', url: 'http://x/api/items', status: 200, ms: 40 }, 3),
11
- ev({ kind: 'route', route: 'Second.Screen', path: ['Second.Screen'] }, 4),
12
- ev({ kind: 'touch', x: 10, y: 20, text: 'Second option', route: 'Second.Screen' }, 5),
13
- ev({ kind: 'store', store: 'session', changed: { step: { from: 1, to: 2 } } }, 6),
14
- ev({ kind: 'touch', x: 5, y: 5, route: 'Second.Screen' }, 7),
15
- ]);
16
- assert.deepEqual(flow.steps, [
17
- { press: { testId: 'primary-button' } },
18
- { waitFor: { route: 'Second.Screen', timeoutMs: 10000 } },
19
- { note: 'requests: GET /api/items 200' },
20
- { press: { text: 'Second option' } },
21
- { note: 'stores changed: session.step' },
22
- { note: 'touch at 5,5 on Second.Screen hit nothing lynkeus could name' },
23
- ]);
24
- });
25
- test('device subcommands map to simctl and adb', () => {
26
- assert.deepEqual(simulatorArgv('ios', 'UDID', 'permissions', ['grant', 'camera'], 'com.example.myapp')[0], {
27
- bin: 'xcrun',
28
- args: ['simctl', 'privacy', 'UDID', 'grant', 'camera', 'com.example.myapp'],
29
- });
30
- assert.deepEqual(simulatorArgv('ios', 'UDID', 'appearance', ['dark'])[0].args, ['simctl', 'ui', 'UDID', 'appearance', 'dark']);
31
- assert.deepEqual(simulatorArgv('ios', 'UDID', 'location', ['-34.6,-58.4'])[0].args, ['simctl', 'location', 'UDID', 'set', '-34.6,-58.4']);
32
- assert.equal(simulatorArgv('ios', 'UDID', 'language', ['de-DE'], 'com.example.myapp').length, 2);
33
- assert.deepEqual(simulatorArgv('android', 'emu', 'permissions', ['grant', 'camera'], 'com.example.myapp')[0].args, [
34
- '-s',
35
- 'emu',
36
- 'shell',
37
- 'pm',
38
- 'grant',
39
- 'com.example.myapp',
40
- 'android.permission.CAMERA',
41
- ]);
42
- assert.throws(() => simulatorArgv('ios', 'UDID', 'permissions', ['grant', 'camera']), /--app/);
43
- assert.throws(() => simulatorArgv('ios', 'UDID', 'appearance', ['blue']), /light\|dark/);
44
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,47 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import fs from 'node:fs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { test } from 'node:test';
6
- import { PNG } from 'pngjs';
7
- import { comparePng, snapshot } from '../commands/snapshot.js';
8
- const png = (w, h, paint) => {
9
- const p = new PNG({ width: w, height: h });
10
- for (let y = 0; y < h; y++)
11
- for (let x = 0; x < w; x++) {
12
- const i = (y * w + x) * 4;
13
- const [r, g, b] = paint(x, y);
14
- p.data[i] = r;
15
- p.data[i + 1] = g;
16
- p.data[i + 2] = b;
17
- p.data[i + 3] = 255;
18
- }
19
- return PNG.sync.write(p);
20
- };
21
- test('pixels that moved are counted and painted into a diff', () => {
22
- const a = png(10, 10, () => [255, 255, 255]);
23
- const b = png(10, 10, (x, y) => (x < 2 && y < 2 ? [0, 0, 0] : [250, 250, 250]));
24
- const c = comparePng(a, b);
25
- assert.equal(c.different, 4); // the 250 vs 255 shade stays under the channel threshold
26
- assert.equal(c.ratio, 0.04);
27
- assert.ok(c.diff);
28
- const d = PNG.sync.read(c.diff);
29
- assert.deepEqual([d.data[0], d.data[1], d.data[2]], [255, 0, 0]);
30
- });
31
- test('a first capture is the baseline; a small change matches; a big one writes a diff', () => {
32
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-snap-'));
33
- const lynkeusDir = path.join(root, '.lynkeus');
34
- const runsDir = path.join(lynkeusDir, 'runs');
35
- const white = png(20, 20, () => [255, 255, 255]);
36
- assert.equal(snapshot(lynkeusDir, 'card', white, { runsDir }).status, 'baseline');
37
- const nearly = png(20, 20, (x, y) => (x === 0 && y === 0 ? [0, 0, 0] : [255, 255, 255]));
38
- assert.equal(snapshot(lynkeusDir, 'card', nearly, { runsDir, tolerance: 0.01 }).status, 'match');
39
- const half = png(20, 20, (x) => (x < 10 ? [0, 0, 0] : [255, 255, 255]));
40
- const r = snapshot(lynkeusDir, 'card', half, { runsDir });
41
- assert.equal(r.status, 'mismatch');
42
- assert.ok(fs.existsSync(r.diffFile));
43
- assert.equal(snapshot(lynkeusDir, 'card', half, { runsDir, update: true }).status, 'baseline');
44
- assert.equal(snapshot(lynkeusDir, 'card', half, { runsDir }).status, 'match');
45
- const other = png(30, 20, () => [255, 255, 255]);
46
- assert.ok(snapshot(lynkeusDir, 'card', other, { runsDir }).comparison?.sizeMismatch);
47
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,127 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
3
- import { describeScreen } from '../device/driver.js';
4
- import { parseTarget, scopedTarget } from '../targets.js';
5
- test('a #-prefixed string is a testId', () => {
6
- assert.deepEqual(parseTarget('#next-button'), { testId: 'next-button' });
7
- });
8
- test('an x,y pair is a point', () => {
9
- assert.deepEqual(parseTarget('100,200'), { x: 100, y: 200 });
10
- });
11
- test('a bare integer is an index', () => {
12
- assert.deepEqual(parseTarget('12'), { i: 12 });
13
- });
14
- test('anything else is text', () => {
15
- assert.deepEqual(parseTarget('Some label'), { text: 'Some label' });
16
- });
17
- test('a testId that contains a comma is still a testId, not a point', () => {
18
- assert.deepEqual(parseTarget('#a,b'), { testId: 'a,b' });
19
- });
20
- test('describeScreen prints the value a QR image carries', () => {
21
- const screen = {
22
- route: 'X',
23
- path: ['X'],
24
- commit: 1,
25
- busy: false,
26
- window: { w: 390, h: 844 },
27
- costMs: 1,
28
- elements: [{ i: 0, kind: 'image', frame: { x: 0, y: 0, w: 168, h: 169 }, enabled: true, depth: 0, accessibilityLabel: 'qr', value: 'QR-TEST-0001' }],
29
- };
30
- assert.match(describeScreen(screen), /image .*value="QR-TEST-0001"/);
31
- });
32
- test('describeScreen lists a view with a testID and leaves plain layout out', () => {
33
- const screen = {
34
- route: 'X',
35
- path: ['X'],
36
- commit: 1,
37
- busy: false,
38
- window: { w: 390, h: 844 },
39
- costMs: 1,
40
- elements: [
41
- { i: 0, kind: 'view', frame: { x: 0, y: 0, w: 390, h: 844 }, enabled: true, depth: 0 },
42
- { i: 1, kind: 'view', frame: { x: 0, y: 0, w: 390, h: 400 }, enabled: true, depth: 1, testId: 'intro-page-advances' },
43
- ],
44
- };
45
- const text = describeScreen(screen);
46
- assert.match(text, /view +#intro-page-advances/);
47
- assert.doesNotMatch(text, /^0? ?view$/m);
48
- });
49
- test('describeScreen leaves out what is not presented, counts it, and lists it with --all', () => {
50
- const el = (i, over) => ({ i, kind: 'button', frame: { x: 0, y: 0, w: 100, h: 40 }, enabled: true, depth: 0, ...over });
51
- const screen = {
52
- route: 'X',
53
- path: ['X'],
54
- commit: 7,
55
- busy: false,
56
- window: { w: 390, h: 844 },
57
- costMs: 3,
58
- elements: [
59
- el(0, { testId: 'tab-a', hidden: 'a11y' }),
60
- el(1, { testId: 'behind', hidden: 'behind-overlay' }),
61
- el(2, { testId: 'slide', hidden: 'inert' }),
62
- el(3, { testId: 'close', enabled: false }),
63
- ],
64
- };
65
- const text = describeScreen(screen);
66
- assert.equal(text.split('\n')[0] ?? '', 'X · 2 hidden: 1 a11y, 1 behind-overlay');
67
- assert.doesNotMatch(text, /#tab-a|#behind/);
68
- assert.match(text, /^button #slide ~inert$/m);
69
- assert.match(text, /^button #close !disabled$/m);
70
- assert.match(describeScreen(screen, { all: true }), /#behind ~behind-overlay/);
71
- assert.match(describeScreen(screen, { frames: true }), /#slide ~inert @50,20$/m);
72
- assert.match(describeScreen(screen, { verbose: true }).split('\n')[0] ?? '', /commit=7 3ms/);
73
- });
74
- test("describeScreen leaves a button's icon out", () => {
75
- const screen = {
76
- route: 'X',
77
- path: ['X'],
78
- commit: 1,
79
- busy: false,
80
- window: { w: 390, h: 844 },
81
- costMs: 1,
82
- elements: [
83
- { i: 0, kind: 'button', frame: { x: 0, y: 0, w: 100, h: 40 }, enabled: true, depth: 0, testId: 'open' },
84
- { i: 1, kind: 'view', frame: { x: 0, y: 0, w: 20, h: 20 }, enabled: true, depth: 1, testId: 'SvgArrow', parent: 0 },
85
- { i: 2, kind: 'view', frame: { x: 0, y: 50, w: 20, h: 20 }, enabled: true, depth: 0, testId: 'page-marker' },
86
- ],
87
- };
88
- assert.doesNotMatch(describeScreen(screen), /SvgArrow/);
89
- assert.match(describeScreen(screen), /#page-marker/);
90
- assert.match(describeScreen(screen, { all: true }), /SvgArrow/);
91
- });
92
- test('describeScreen gives an index only to what cannot be named apart', () => {
93
- const el = (i, over) => ({ i, kind: 'button', frame: { x: 0, y: 0, w: 100, h: 40 }, enabled: true, depth: 0, ...over });
94
- const screen = {
95
- route: 'X',
96
- path: ['X'],
97
- commit: 1,
98
- busy: false,
99
- window: { w: 390, h: 844 },
100
- costMs: 1,
101
- elements: [
102
- el(0, { testId: 'save' }),
103
- el(1, { testId: 'like' }),
104
- el(2, { testId: 'like' }),
105
- el(3, { kind: 'text', text: 'Hello' }),
106
- el(4, { kind: 'view', testId: 'marker', accessibilityLabel: 'marker' }),
107
- el(5, {}),
108
- ],
109
- };
110
- assert.deepEqual(describeScreen(screen).split('\n').slice(1), [
111
- 'button #save',
112
- '1 button #like',
113
- '2 button #like',
114
- 'text "Hello"',
115
- 'view #marker',
116
- '5 button',
117
- ]);
118
- });
119
- test('a [n] suffix picks the nth match of a testId or text', () => {
120
- assert.deepEqual(parseTarget('#field[2]'), { testId: 'field', nth: 2 });
121
- assert.deepEqual(parseTarget('Ciudad[0]'), { text: 'Ciudad', nth: 0 });
122
- });
123
- test('--in and --nth narrow a target', () => {
124
- assert.deepEqual(scopedTarget('#like-message-button', { in: 'hi from Ana' }), { testId: 'like-message-button', within: { text: 'hi from Ana' } });
125
- assert.deepEqual(scopedTarget('#like-message-button', { in: '#feed', nth: 2 }), { testId: 'like-message-button', within: { testId: 'feed' }, nth: 2 });
126
- assert.deepEqual(scopedTarget('100,200', { in: '#feed' }), { x: 100, y: 200 });
127
- });
@@ -1 +0,0 @@
1
- export {};