lynkeus 0.4.0 → 0.5.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 (81) hide show
  1. package/lib/cases/coverage.d.ts +31 -0
  2. package/lib/cases/coverage.js +82 -0
  3. package/lib/cases/draft.d.ts +21 -0
  4. package/lib/cases/draft.js +53 -0
  5. package/lib/cases/fixtures.d.ts +11 -0
  6. package/lib/cases/fixtures.js +59 -8
  7. package/lib/cases/frames.d.ts +6 -0
  8. package/lib/cases/frames.js +28 -0
  9. package/lib/cases/nativeLogs.d.ts +14 -0
  10. package/lib/cases/nativeLogs.js +111 -0
  11. package/lib/cases/runDirs.d.ts +13 -0
  12. package/lib/cases/runDirs.js +48 -0
  13. package/lib/cases/runfile.d.ts +5 -1
  14. package/lib/cases/runfile.js +67 -4
  15. package/lib/cases/runner.d.ts +56 -1
  16. package/lib/cases/runner.js +309 -33
  17. package/lib/cases/templates.d.ts +2 -2
  18. package/lib/cases/templates.js +20 -2
  19. package/lib/cases/traces.js +4 -1
  20. package/lib/cli.js +25 -2
  21. package/lib/commands/cases.d.ts +8 -0
  22. package/lib/commands/cases.js +102 -19
  23. package/lib/commands/ci.d.ts +13 -0
  24. package/lib/commands/ci.js +184 -0
  25. package/lib/commands/coverage.d.ts +1 -0
  26. package/lib/commands/coverage.js +151 -0
  27. package/lib/commands/device.js +32 -2
  28. package/lib/commands/drive.js +3 -1
  29. package/lib/commands/fixtures.d.ts +7 -0
  30. package/lib/commands/fixtures.js +94 -0
  31. package/lib/commands/headless.d.ts +11 -0
  32. package/lib/commands/headless.js +69 -3
  33. package/lib/commands/map.d.ts +2 -0
  34. package/lib/commands/map.js +18 -14
  35. package/lib/commands/os.js +43 -10
  36. package/lib/commands/report.d.ts +4 -0
  37. package/lib/commands/report.js +154 -18
  38. package/lib/config.d.ts +25 -0
  39. package/lib/device/agentVersion.d.ts +7 -0
  40. package/lib/device/agentVersion.js +14 -0
  41. package/lib/device/devices.js +6 -0
  42. package/lib/knowledge/analyticsTraffic.d.ts +10 -0
  43. package/lib/knowledge/analyticsTraffic.js +109 -0
  44. package/lib/knowledge/errors.js +6 -0
  45. package/lib/knowledge/events.d.ts +2 -0
  46. package/lib/knowledge/why.d.ts +2 -0
  47. package/lib/knowledge/why.js +1 -1
  48. package/lib/mcp/server.js +16 -4
  49. package/lib/runtime.d.ts +9 -1
  50. package/lib/runtime.js +25 -3
  51. package/package.json +7 -4
  52. package/src/cases/coverage.ts +99 -0
  53. package/src/cases/draft.ts +75 -0
  54. package/src/cases/fixtures.ts +57 -8
  55. package/src/cases/frames.ts +30 -0
  56. package/src/cases/nativeLogs.ts +112 -0
  57. package/src/cases/runDirs.ts +48 -0
  58. package/src/cases/runfile.ts +64 -4
  59. package/src/cases/runner.ts +359 -37
  60. package/src/cases/templates.ts +20 -2
  61. package/src/cases/traces.ts +3 -1
  62. package/src/cli.ts +25 -2
  63. package/src/commands/cases.ts +111 -18
  64. package/src/commands/ci.ts +189 -0
  65. package/src/commands/coverage.ts +147 -0
  66. package/src/commands/device.ts +27 -2
  67. package/src/commands/drive.ts +3 -1
  68. package/src/commands/fixtures.ts +102 -0
  69. package/src/commands/headless.ts +66 -3
  70. package/src/commands/map.ts +20 -15
  71. package/src/commands/os.ts +38 -10
  72. package/src/commands/report.ts +160 -16
  73. package/src/config.ts +15 -0
  74. package/src/device/agentVersion.ts +17 -0
  75. package/src/device/devices.ts +5 -0
  76. package/src/knowledge/analyticsTraffic.ts +142 -0
  77. package/src/knowledge/errors.ts +6 -0
  78. package/src/knowledge/events.ts +2 -1
  79. package/src/knowledge/why.ts +3 -1
  80. package/src/mcp/server.ts +16 -4
  81. package/src/runtime.ts +24 -3
@@ -0,0 +1,151 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { coverageOf, impactOf, readSeen } from '../cases/coverage.js';
4
+ import { draftCase } from '../cases/draft.js';
5
+ import { Fixtures } from '../cases/fixtures.js';
6
+ import { readCases } from '../cases/format.js';
7
+ import { edgesFile } from '../config.js';
8
+ import * as edges from '../knowledge/edges.js';
9
+ import { plan } from '../knowledge/plan.js';
10
+ import { screensAffectedBy } from '../knowledge/query.js';
11
+ import { define } from '../registry.js';
12
+ import { caseFiles } from './cases.js';
13
+ import { changedSources } from './map.js';
14
+ // The project's cases and what they cover, read the way `case run` reads them.
15
+ const coverageFor = (ctx, seenFlag) => {
16
+ const config = ctx.config.cases ?? {};
17
+ const home = config.base ?? ctx.root;
18
+ const files = caseFiles([], home, config.dir);
19
+ if (typeof files === 'string')
20
+ return files;
21
+ const cases = files.flatMap(readCases);
22
+ const seenDir = path.resolve(ctx.root, typeof seenFlag === 'string' ? seenFlag : '.lynkeus/seen');
23
+ return { cases, coverage: coverageOf(cases, Object.keys(ctx.graph().screens).sort(), readSeen(seenDir)) };
24
+ };
25
+ export const coverageCommands = [
26
+ define({
27
+ name: 'case coverage',
28
+ group: 'runs',
29
+ summary: 'Which of the app’s screens the cases touch, which none does, and which a case names that the app no longer has',
30
+ details: 'A screen counts as touched when a case goes to it (`app.nav`), waits for it (`app.wait`), checks it (`app.see: { route }`), or went through it on its last passing run (`case run --seen <dir>`, read from .lynkeus/seen unless --seen says where). The screens come from the route graph (`lynkeus map build`).',
31
+ flags: {
32
+ route: { type: 'string', help: 'only this screen: the cases that touch it', value: 'Route' },
33
+ seen: { type: 'string', help: 'where `case run --seen` kept the screens of passing cases (default .lynkeus/seen)', value: 'dir' },
34
+ },
35
+ needs: 'graph',
36
+ session: false,
37
+ mcp: 'core',
38
+ run: async (ctx) => {
39
+ const read = coverageFor(ctx, ctx.flags.seen);
40
+ if (typeof read === 'string')
41
+ return { text: read, code: 1 };
42
+ const { cases, coverage } = read;
43
+ if (typeof ctx.flags.route === 'string') {
44
+ const ids = coverage.byRoute[ctx.flags.route] ?? [];
45
+ return { text: `${ctx.flags.route}: ${ids.length ? ids.join(', ') : 'no case'}`, json: { route: ctx.flags.route, cases: ids } };
46
+ }
47
+ const pct = coverage.routes ? Math.round((100 * coverage.covered.length) / coverage.routes) : 0;
48
+ const top = Object.entries(coverage.byRoute)
49
+ .filter(([r]) => !coverage.unknown.includes(r))
50
+ .sort((a, b) => b[1].length - a[1].length)
51
+ .slice(0, 8);
52
+ const lines = [
53
+ `${coverage.covered.length} of ${coverage.routes} screens touched by a case (${pct}%), from ${cases.length} case${cases.length === 1 ? '' : 's'}`,
54
+ ...(top.length ? ['most covered:', ...top.map(([r, ids]) => ` ${r.padEnd(34)} ${ids.length}`)] : []),
55
+ ...(coverage.unknown.length
56
+ ? ['named by a case, unknown to the app (renamed? a typo?):', ...coverage.unknown.map((r) => ` ${r} ← ${coverage.byRoute[r].join(', ')}`)]
57
+ : []),
58
+ `${coverage.uncovered.length} screen${coverage.uncovered.length === 1 ? '' : 's'} no case touches${coverage.uncovered.length ? ':' : ''}`,
59
+ ...coverage.uncovered.slice(0, 30).map((r) => ` ${r}`),
60
+ ...(coverage.uncovered.length > 30 ? [` … ${coverage.uncovered.length - 30} more (--json)`] : []),
61
+ ];
62
+ return { text: lines.join('\n'), json: coverage };
63
+ },
64
+ }),
65
+ define({
66
+ name: 'case impact',
67
+ group: 'runs',
68
+ summary: 'The cases a branch’s changes reach, to run only those, and the reached screens no case covers',
69
+ details: 'The screens the diff against --base touches or imports (as `map check` finds them), crossed with what each case covers (as `case coverage` finds it). --files prints one case file per line: `lynkeus case run $(lynkeus case impact --files)`.',
70
+ flags: {
71
+ base: { type: 'string', help: 'git ref to diff against', default: 'main', value: 'ref' },
72
+ depth: { type: 'number', help: 'how many imports away a change still reaches a screen', default: 4, value: 'n' },
73
+ seen: { type: 'string', help: 'where `case run --seen` kept the screens of passing cases (default .lynkeus/seen)', value: 'dir' },
74
+ files: { type: 'boolean', help: 'print only the case files, one per line' },
75
+ },
76
+ needs: 'graph',
77
+ session: false,
78
+ mcp: 'core',
79
+ run: async (ctx) => {
80
+ const read = coverageFor(ctx, ctx.flags.seen);
81
+ if (typeof read === 'string')
82
+ return { text: read, code: 1 };
83
+ const base = ctx.flags.base;
84
+ const changed = changedSources(ctx.root, base, ctx.err);
85
+ const affected = screensAffectedBy(ctx.graph(), changed, ctx.flags.depth);
86
+ const impact = impactOf(affected, read.coverage);
87
+ if (ctx.flags.files)
88
+ return { text: [...new Set(impact.cases.map((c) => c.file))].join('\n'), json: impact };
89
+ const lines = [
90
+ `${changed.length} source file${changed.length === 1 ? '' : 's'} changed against ${base}; they reach ${new Set(affected.map((a) => a.route)).size} screen(s)`,
91
+ ...(impact.cases.length
92
+ ? [`cases to run (${impact.cases.length}):`, ...impact.cases.map((c) => ` ${c.id.padEnd(46)} ${c.routes.join(', ')} (via ${c.via})`)]
93
+ : ['no case touches them']),
94
+ ...(impact.untested.length ? [`reached, and touched by no case (${impact.untested.length}):`, ...impact.untested.map((r) => ` ${r}`)] : []),
95
+ ];
96
+ return { text: lines.join('\n'), json: { base, changed, ...impact } };
97
+ },
98
+ }),
99
+ define({
100
+ name: 'case new',
101
+ group: 'runs',
102
+ summary: 'Draft a case for a screen: the way there, the backend setup to pick from, and the checks left to write',
103
+ details: 'Prints a case to finish: the frontmatter with the questions a reader needs answered, the setup the project’s fixtures server offers (from `lynkeus.describe`, commented out: what state the case needs is the author’s call), the presses from --from (default `cases.start`) to the screen as `map plan` knows them, and the checks to write. --write saves it as <cases dir>/<id>.md, never over a file that is there.',
104
+ positionals: [{ name: 'id', help: 'the case id, which is also its file name', required: true }],
105
+ flags: {
106
+ to: { type: 'string', help: 'the screen the case is about', value: 'Route' },
107
+ from: { type: 'string', help: 'where it begins (default: cases.start in lynkeus.config.json)', value: 'Route' },
108
+ title: { type: 'string', help: 'what a person can do there, as the report will title it', value: 'text' },
109
+ write: { type: 'boolean', help: 'save it in the cases folder instead of printing it' },
110
+ },
111
+ needs: 'graph',
112
+ session: false,
113
+ mcp: 'core',
114
+ run: async (ctx) => {
115
+ const config = ctx.config.cases ?? {};
116
+ const id = ctx.args[0];
117
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(id))
118
+ return { text: `${id}: an id is lower case letters, digits, dots, dashes and underscores`, code: 2 };
119
+ const to = ctx.flags.to;
120
+ const from = ctx.flags.from ?? config.start;
121
+ if (!to)
122
+ return { text: 'case new needs --to <Route>, the screen the case is about', code: 2 };
123
+ if (!from)
124
+ return { text: 'case new needs --from <Route>, or cases.start in lynkeus.config.json', code: 2 };
125
+ const graph = ctx.graph();
126
+ if (!graph.screens[to])
127
+ return { text: `${to}: the app has no such screen (lynkeus map routes lists them)`, code: 1 };
128
+ const server = config.fixtures ? new Fixtures(config.fixtures, config.base ?? ctx.root) : undefined;
129
+ const described = server ? await server.describe().finally(() => server.close()) : undefined;
130
+ const text = draftCase({
131
+ id,
132
+ title: ctx.flags.title,
133
+ from,
134
+ to,
135
+ path: plan(graph, from, to, edges.load(edgesFile(ctx.root))) ?? undefined,
136
+ fixtures: described?.details,
137
+ fixturesSilent: !!server && !described,
138
+ macros: Object.keys(config.macros ?? {}),
139
+ });
140
+ if (!ctx.flags.write)
141
+ return { text, json: { id, text } };
142
+ const dir = path.resolve(config.base ?? ctx.root, config.dir ?? 'cases');
143
+ const file = path.join(dir, `${id}.md`);
144
+ if (fs.existsSync(file))
145
+ return { text: `${path.relative(ctx.root, file)} is there already; pick another id or edit it`, code: 1 };
146
+ fs.mkdirSync(dir, { recursive: true });
147
+ fs.writeFileSync(file, text);
148
+ return { text: `wrote ${path.relative(ctx.root, file)}`, json: { id, file } };
149
+ },
150
+ }),
151
+ ];
@@ -76,8 +76,29 @@ export const simulatorArgv = (platform, deviceId, sub, args, appId) => {
76
76
  { bin: 'xcrun', args: ['simctl', 'launch', deviceId, app, '-AppleLanguages', `(${tag})`, '-AppleLocale', locale] },
77
77
  ];
78
78
  }
79
+ case 'biometrics': {
80
+ // The simulator's Face ID / Touch ID enrollment, as Features ▸ Face ID ▸ Enrolled toggles it; which of the two
81
+ // the device has is its model's.
82
+ const [available] = args;
83
+ if (!available)
84
+ throw new Error('lynkeus os biometrics --available FaceID|TouchID|none on a simulator');
85
+ const enrolled = available === 'none' ? '0' : '1';
86
+ return [
87
+ { bin: 'xcrun', args: ['simctl', 'spawn', deviceId, 'notifyutil', '-s', 'com.apple.BiometricKit.enrollmentChanged', enrolled] },
88
+ { bin: 'xcrun', args: ['simctl', 'spawn', deviceId, 'notifyutil', '-p', 'com.apple.BiometricKit.enrollmentChanged'] },
89
+ ];
90
+ }
91
+ case 'appstate': {
92
+ // Another app in front puts this one in the background; launching it again brings it back as it was.
93
+ const [state] = args;
94
+ if (state === 'background')
95
+ return [{ bin: 'xcrun', args: ['simctl', 'launch', deviceId, 'com.apple.Preferences'] }];
96
+ if (state === 'active')
97
+ return [{ bin: 'xcrun', args: ['simctl', 'launch', deviceId, needApp()] }];
98
+ throw new Error('lynkeus os appstate background|active on a simulator');
99
+ }
79
100
  default:
80
- throw new Error(`unknown os subcommand "${sub}"; one of permissions, push, appearance, location, language`);
101
+ throw new Error(`unknown os subcommand "${sub}"; one of permissions, push, appearance, location, language, appstate`);
81
102
  }
82
103
  }
83
104
  const adb = (a) => ({ bin: 'adb', args: ['-s', deviceId, ...a] });
@@ -101,8 +122,17 @@ export const simulatorArgv = (platform, deviceId, sub, args, appId) => {
101
122
  const [lat, lon] = where.split(',');
102
123
  return [adb(['emu', 'geo', 'fix', lon, lat])];
103
124
  }
125
+ case 'appstate': {
126
+ // Home sends the app to the background; its launcher intent brings the same task back, not a new one.
127
+ const [state] = args;
128
+ if (state === 'background')
129
+ return [adb(['shell', 'input', 'keyevent', 'KEYCODE_HOME'])];
130
+ if (state === 'active')
131
+ return [adb(['shell', 'monkey', '--pct-syskeys', '0', '-p', needApp(), '-c', 'android.intent.category.LAUNCHER', '1'])];
132
+ throw new Error('lynkeus os appstate background|active on an emulator or a phone');
133
+ }
104
134
  default:
105
- throw new Error(`"${sub}" is not available on Android through lynkeus; permissions, appearance and location are`);
135
+ throw new Error(`"${sub}" is not available on Android through lynkeus; permissions, appearance, location and appstate are`);
106
136
  }
107
137
  };
108
138
  export const simulatorCommand = async (platform, deviceId, sub, args, appId) => {
@@ -165,7 +165,7 @@ export const driveCommands = [
165
165
  define({
166
166
  name: 'wait',
167
167
  mcp: 'core',
168
- mcpOmit: ['nth'],
168
+ mcpOmit: ['nth', 'since'],
169
169
  group: 'drive',
170
170
  summary: 'Until a route or element appears (or leaves), or a number of ms',
171
171
  positionals: [{ name: 'what', help: `Route | route:index | ${TARGET_HELP} | 800 (ms)`, required: true }],
@@ -174,6 +174,7 @@ export const driveCommands = [
174
174
  gone: { type: 'boolean', help: 'until it leaves the screen' },
175
175
  scroll: { type: 'boolean', help: 'scroll until it is in the window' },
176
176
  timeout: { type: 'number', help: 'give up after', default: 10000, value: 'ms' },
177
+ since: { type: 'number', help: 'a trace mark: a route reached after it counts, even if already left', value: 'seq' },
177
178
  },
178
179
  needs: 'app',
179
180
  run: async (ctx) => {
@@ -188,6 +189,7 @@ export const driveCommands = [
188
189
  gone: ctx.flags.gone ? true : undefined,
189
190
  scroll: ctx.flags.scroll ? true : undefined,
190
191
  timeoutMs: ctx.flags.timeout,
192
+ since: ctx.flags.since,
191
193
  }),
192
194
  };
193
195
  },
@@ -0,0 +1,7 @@
1
+ import { type Description } from '../cases/fixtures.js';
2
+ /** What a server keeps of the contract and what it leaves out, as lines a person can act on. */
3
+ export declare const checkDescription: (d: Description | undefined) => {
4
+ ok: boolean;
5
+ lines: string[];
6
+ };
7
+ export declare const fixturesCommands: import("../registry.js").Command[];
@@ -0,0 +1,94 @@
1
+ import { FIXTURES_PROTOCOL } from 'lynkeus-protocol';
2
+ import { DESCRIBE, Fixtures, lastDescription, readDescription } from '../cases/fixtures.js';
3
+ import { define } from '../registry.js';
4
+ const paramsOf = (c) => Object.entries(c.params ?? {})
5
+ .map(([k, p]) => `${k}${p.required ? '' : '?'}${p.values ? `=${p.values.join('|')}` : p.type ? `:${p.type}` : ''}`)
6
+ .join(' ');
7
+ /** What a server keeps of the contract and what it leaves out, as lines a person can act on. */
8
+ export const checkDescription = (d) => {
9
+ if (!d)
10
+ return { ok: false, lines: ['✗ `lynkeus.describe` got no list of commands: answer {"commands": [...]} (docs/FIXTURES.md)'] };
11
+ const lines = [];
12
+ let ok = true;
13
+ if (d.down) {
14
+ ok = false;
15
+ lines.push(`✗ the server runs but cannot serve: ${d.down}`);
16
+ }
17
+ if (d.protocol !== undefined && d.protocol > FIXTURES_PROTOCOL) {
18
+ ok = false;
19
+ lines.push(`✗ it speaks fixtures protocol ${d.protocol}; this lynkeus knows ${FIXTURES_PROTOCOL}`);
20
+ }
21
+ const names = new Set();
22
+ for (const c of d.details) {
23
+ if (names.has(c.name)) {
24
+ ok = false;
25
+ lines.push(`✗ ${c.name} is listed twice`);
26
+ }
27
+ names.add(c.name);
28
+ }
29
+ const bare = d.details.filter((c) => !c.summary);
30
+ lines.push(`✓ ${d.commands.length} command${d.commands.length === 1 ? '' : 's'}`);
31
+ if (bare.length)
32
+ lines.push(`· ${bare.length} without a summary (${bare
33
+ .slice(0, 6)
34
+ .map((c) => c.name)
35
+ .join(', ')}${bare.length > 6 ? ', …' : ''}): a summary and params let authors and models pick a setup`);
36
+ return { ok, lines };
37
+ };
38
+ export const fixturesCommands = [
39
+ define({
40
+ name: 'fixtures describe',
41
+ group: 'runs',
42
+ summary: 'List what the project’s fixtures server can do: each command, what it makes or reads, its parameters',
43
+ details: 'Starts the command `cases.fixtures` names, asks `lynkeus.describe`, and prints each command with its summary and parameters, for whoever writes a case. A server that answers names only shows names. What it answered last (here or before a run) is kept in .lynkeus and shown for a day without starting it; --fresh asks again.',
44
+ flags: { fresh: { type: 'boolean', help: 'ask the server even when it described itself in the last day' } },
45
+ needs: 'nothing',
46
+ session: false,
47
+ mcp: 'core',
48
+ run: async (ctx) => {
49
+ const config = ctx.config.cases ?? {};
50
+ if (!config.fixtures)
51
+ return { text: 'the project declares no fixtures (cases.fixtures in lynkeus.config.json)', code: 1 };
52
+ const root = config.base ?? ctx.root;
53
+ const kept = ctx.flags.fresh ? undefined : lastDescription(root, config.fixtures.command);
54
+ const recent = kept && Date.now() - kept.at < 24 * 3600_000 ? kept : undefined;
55
+ let d = recent?.description;
56
+ if (!d) {
57
+ const server = new Fixtures(config.fixtures, root);
58
+ d = await server.describe().finally(() => server.close());
59
+ }
60
+ if (!d)
61
+ return { text: 'the fixtures server did not describe itself (it answers `lynkeus.describe` with {"commands": [...]})', code: 1 };
62
+ const text = d.details.map((c) => `${c.name}${c.params ? ` ${paramsOf(c)}` : ''}${c.summary ? `\n ${c.summary}` : ''}`).join('\n');
63
+ const age = recent ? `\n(as the server described itself ${Math.round((Date.now() - recent.at) / 60_000)} min ago; --fresh asks it again)` : '';
64
+ return { text: d.down ? `${text}\n(the server cannot serve now: ${d.down})` : `${text}${age}`, json: d };
65
+ },
66
+ }),
67
+ define({
68
+ name: 'fixtures check',
69
+ group: 'runs',
70
+ summary: 'Tell whether the project’s fixtures server keeps the fixtures protocol',
71
+ details: 'Starts the server, asks `lynkeus.describe`, and checks the answer against the contract in docs/FIXTURES.md: a list of commands, no name twice, a protocol version this lynkeus knows, and whether it is up. It changes nothing in the backend.',
72
+ needs: 'nothing',
73
+ session: false,
74
+ mcp: 'core',
75
+ run: async (ctx) => {
76
+ const config = ctx.config.cases ?? {};
77
+ if (!config.fixtures)
78
+ return { text: 'the project declares no fixtures (cases.fixtures in lynkeus.config.json)', code: 1 };
79
+ const server = new Fixtures(config.fixtures, config.base ?? ctx.root);
80
+ // Its own call, not describe(): a server that never answers is told apart from one that answers wrong.
81
+ const answered = await server.call(DESCRIBE, {}).then((answer) => ({ answer }), (error) => ({ error: error.message }));
82
+ server.close();
83
+ if ('error' in answered)
84
+ return {
85
+ text: `✗ the fixtures server gave no answer to lynkeus.describe: ${answered.error}\n (is its backend up? the command is \`${config.fixtures.command}\`)`,
86
+ json: { ok: false, error: answered.error },
87
+ code: 1,
88
+ };
89
+ const d = readDescription(answered.answer);
90
+ const { ok, lines } = checkDescription(d);
91
+ return { text: lines.join('\n'), json: { ok, description: d }, code: ok ? 0 : 1 };
92
+ },
93
+ }),
94
+ ];
@@ -1 +1,12 @@
1
+ /**
2
+ * A jest cache of its own for each API base. Babel inlines `process.env` into the code it transforms and jest
3
+ * keeps that output, so a host started with another API address kept calling the first one it was started with.
4
+ */
5
+ export declare const cacheFor: (root: string, env: Record<string, string | undefined>, apiEnv?: string) => string;
6
+ /**
7
+ * What jest is told when the app has not said it itself, so an app with no jest set up runs headless as it is:
8
+ * the preset its framework needs (jest-expo for Expo, React Native's otherwise), and which packages under node_modules to transpile.
9
+ */
10
+ /** The app's react folder, when lynkeus-headless would load another copy; undefined when they are one. */
11
+ export declare const duplicateReact: (root: string) => string | undefined;
1
12
  export declare const headlessCommands: import("../registry.js").Command[];
@@ -1,5 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
2
3
  import fs from 'node:fs';
4
+ import { createRequire } from 'node:module';
3
5
  import path from 'node:path';
4
6
  import { lynkeusDir, portFromEnv, readToken } from '../config.js';
5
7
  import { describeExplanations, explainError } from '../knowledge/errors.js';
@@ -30,6 +32,17 @@ hostApp(() => <entry.App />, { ${options.join(', ')} });
30
32
  const JEST_CONFIGS = ['jest.config.js', 'jest.config.cjs', 'jest.config.mjs', 'jest.config.ts', 'jest.config.json'];
31
33
  // React Native and most of what an app adds to it ship untranspiled ES modules, which jest leaves alone unless told.
32
34
  const apiFromConfig = (config) => (config.apiUrl ? { [config.apiEnv ?? 'API_URL']: config.apiUrl } : {});
35
+ /**
36
+ * A jest cache of its own for each API base. Babel inlines `process.env` into the code it transforms and jest
37
+ * keeps that output, so a host started with another API address kept calling the first one it was started with.
38
+ */
39
+ export const cacheFor = (root, env, apiEnv = 'API_URL') => {
40
+ const key = createHash('sha256')
41
+ .update(`${apiEnv}=${env[apiEnv] ?? ''}`)
42
+ .digest('hex')
43
+ .slice(0, 12);
44
+ return path.join(lynkeusDir(root), 'jest-cache', key);
45
+ };
33
46
  const TRANSFORM_PACKAGES = '((jest-)?react-native[^/]*|@react-native[^/]*|@react-navigation|react-navigation|expo(nent)?|@expo(nent)?/.*|expo-.*|@unimodules|unimodules|@shopify|@gorhom|@sentry/react-native|lynkeus-[^/]+|yoga-layout|@testing-library|immer|@reduxjs/toolkit|react-redux|redux|reselect|@expo-google-fonts|native-base|standard-navigation)';
34
47
  /**
35
48
  * The import aliases in tsconfig.json's `paths`, as jest mappings, the longest first: \`@/assets/*\` has to be
@@ -55,6 +68,22 @@ const aliasesOf = (root) => {
55
68
  * What jest is told when the app has not said it itself, so an app with no jest set up runs headless as it is:
56
69
  * the preset its framework needs (jest-expo for Expo, React Native's otherwise), and which packages under node_modules to transpile.
57
70
  */
71
+ /** The app's react folder, when lynkeus-headless would load another copy; undefined when they are one. */
72
+ export const duplicateReact = (root) => {
73
+ const require = createRequire(import.meta.url);
74
+ try {
75
+ // Its exports leave out package.json: from its entry up to the folder that holds one.
76
+ let headless = path.dirname(fs.realpathSync(require.resolve('lynkeus-headless', { paths: [root] })));
77
+ while (!fs.existsSync(path.join(headless, 'package.json')) && path.dirname(headless) !== headless)
78
+ headless = path.dirname(headless);
79
+ const ours = path.dirname(require.resolve('react/package.json', { paths: [root] }));
80
+ const theirs = path.dirname(require.resolve('react/package.json', { paths: [headless] }));
81
+ return fs.realpathSync(ours) === fs.realpathSync(theirs) ? undefined : ours;
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ };
58
87
  const jestDefaults = (root) => {
59
88
  const file = JEST_CONFIGS.find((f) => fs.existsSync(path.join(root, f)));
60
89
  let config = file ? fs.readFileSync(path.join(root, file), 'utf8') : '';
@@ -66,13 +95,23 @@ const jestDefaults = (root) => {
66
95
  catch { }
67
96
  }
68
97
  const flags = [];
98
+ const react = duplicateReact(root);
99
+ // lynkeus-headless with a React of its own (a linked package, a workspace, pnpm) breaks every hook: the app and
100
+ // the renderer must share the app's copy.
101
+ const sameReact = react ? { '^react$': react, '^react/(.*)$': `${react}/$1` } : {};
69
102
  if (!config) {
70
103
  // A style sheet imported from native code (Expo's templates import global.css) means nothing to jest, even
71
104
  // behind an alias, so it is mapped first. Jest merges these with the preset's own mappings.
72
105
  const stub = path.join(lynkeusDir(root), 'style-stub.js');
73
106
  fs.mkdirSync(path.dirname(stub), { recursive: true });
74
107
  fs.writeFileSync(stub, 'module.exports = {};\n');
75
- flags.push('--preset', jestPreset(root), `--moduleNameMapper=${JSON.stringify({ '\\.(css|less|sass|scss)$': stub, ...aliasesOf(root) })}`);
108
+ flags.push('--preset', jestPreset(root), `--moduleNameMapper=${JSON.stringify({ '\\.(css|less|sass|scss)$': stub, ...aliasesOf(root), ...sameReact })}`);
109
+ }
110
+ else if (react) {
111
+ if (config.includes('moduleNameMapper'))
112
+ process.stderr.write(`lynkeus: lynkeus-headless finds another copy of react than the app's; add to moduleNameMapper in the app's jest config: '^react$': '${react}', '^react/(.*)$': '${react}/$1'\n`);
113
+ else
114
+ flags.push(`--moduleNameMapper=${JSON.stringify(sameReact)}`);
76
115
  }
77
116
  // Two that must stay as they are, as jest-expo leaves them: babel running itself, or Reanimated's plugin, fails.
78
117
  if (!config.includes('transformIgnorePatterns'))
@@ -175,17 +214,44 @@ export const headlessCommands = [
175
214
  fs.writeFileSync(testFile, hostTest(rel.startsWith('.') ? rel : `./${rel}`, port, ctx.config, path.basename(ctx.root)));
176
215
  // Node resolves `localhost` to ::1 first; local backends usually listen on IPv4 only.
177
216
  const nodeOptions = [process.env.NODE_OPTIONS, '--dns-result-order=ipv4first'].filter(Boolean).join(' ');
178
- const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...jestDefaults(ctx.root), ...ctx.passthrough], {
217
+ // LYNKEUS_HEADLESS tells the app's jest setup a host is starting, not its unit tests: a setup that stubs a native
218
+ // SDK whole can leave it real there, so the call reaches the stand-in `nativeMocks` shows.
219
+ const env = { ...apiFromConfig(ctx.config), ...process.env, LYNKEUS_TOKEN: token, LYNKEUS_HEADLESS: '1', CI: '1', NODE_OPTIONS: nodeOptions };
220
+ const cache = `--cacheDirectory=${cacheFor(ctx.root, env, ctx.config.apiEnv)}`;
221
+ const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...jestDefaults(ctx.root), cache, ...ctx.passthrough], {
179
222
  cwd: ctx.root,
180
223
  stdio: ['inherit', 'inherit', 'pipe'],
181
- env: { ...apiFromConfig(ctx.config), ...process.env, LYNKEUS_TOKEN: token, CI: '1', NODE_OPTIONS: nodeOptions },
224
+ env,
225
+ // Its own process group: npx, jest and the host it runs end together. Stopping only npx left the
226
+ // host dialing the port, where a later run's driver took it for the app it meant to drive.
227
+ detached: true,
228
+ });
229
+ const stop = (signal) => {
230
+ try {
231
+ if (child.pid)
232
+ process.kill(-child.pid, signal);
233
+ }
234
+ catch {
235
+ // already gone
236
+ }
237
+ };
238
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
239
+ const forward = signals.map((signal) => {
240
+ const handler = () => stop(signal);
241
+ process.on(signal, handler);
242
+ return () => process.off(signal, handler);
182
243
  });
244
+ const onExit = () => stop('SIGTERM');
245
+ process.on('exit', onExit);
183
246
  let tail = '';
184
247
  child.stderr?.on('data', (chunk) => {
185
248
  process.stderr.write(chunk);
186
249
  tail = (tail + chunk.toString()).slice(-20000);
187
250
  });
188
251
  const code = await new Promise((resolve) => child.on('exit', (c) => resolve(c ?? 0)));
252
+ for (const off of forward)
253
+ off();
254
+ process.off('exit', onExit);
189
255
  if (code) {
190
256
  const found = explainError(tail);
191
257
  if (found.length)
@@ -1,2 +1,4 @@
1
+ /** Source files a branch changed against `base`, committed or not. */
2
+ export declare const changedSources: (root: string, base: string, err: (line: string) => void) => string[];
1
3
  export declare const mapCommands: import("../registry.js").Command[];
2
4
  export declare const mapCheckWorkflow: (depth: number) => string;
@@ -25,6 +25,23 @@ const describeAffected = (affected, chain) => {
25
25
  }
26
26
  return lines;
27
27
  };
28
+ /** Source files a branch changed against `base`, committed or not. */
29
+ export const changedSources = (root, base, err) => {
30
+ const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' });
31
+ try {
32
+ const names = `${git('diff', '--name-only', `${base}...HEAD`)}\n${git('diff', '--name-only', 'HEAD')}`;
33
+ return [
34
+ ...new Set(names
35
+ .split('\n')
36
+ .map((l) => l.trim())
37
+ .filter((l) => /\.[cm]?[jt]sx?$/.test(l))),
38
+ ];
39
+ }
40
+ catch (error) {
41
+ err(`could not diff against ${base}: ${error instanceof Error ? error.message : String(error)}`);
42
+ return [];
43
+ }
44
+ };
28
45
  export const mapCommands = [
29
46
  define({
30
47
  name: 'map build',
@@ -214,20 +231,7 @@ export const mapCommands = [
214
231
  run: async (ctx) => {
215
232
  const graph = ctx.graph();
216
233
  const base = ctx.flags.base;
217
- const git = (...args) => execFileSync('git', args, { cwd: ctx.root, encoding: 'utf8' });
218
- let changed = [];
219
- try {
220
- const names = `${git('diff', '--name-only', `${base}...HEAD`)}\n${git('diff', '--name-only', 'HEAD')}`;
221
- changed = [
222
- ...new Set(names
223
- .split('\n')
224
- .map((l) => l.trim())
225
- .filter((l) => /\.[cm]?[jt]sx?$/.test(l))),
226
- ];
227
- }
228
- catch (error) {
229
- ctx.err(`could not diff against ${base}: ${error instanceof Error ? error.message : String(error)}`);
230
- }
234
+ const changed = changedSources(ctx.root, base, ctx.err);
231
235
  const affected = screensAffectedBy(graph, changed, ctx.flags.depth);
232
236
  const lines = [`changed against ${base}: ${changed.length} source file(s)`, ...describeAffected(affected, true)];
233
237
  if (!affected.length && changed.length)
@@ -30,11 +30,27 @@ export const osCommands = [
30
30
  define({
31
31
  name: 'os appstate',
32
32
  group: 'os',
33
- summary: 'Headless: AppState listeners see the change (background, then active: what refetches or re-locks)',
34
- positionals: [{ name: 'state', help: 'active | background | inactive', required: true }],
33
+ summary: 'AppState listeners see the change (background, then active: what refetches or re-locks); a host answers it, a simulator or an emulator does it for real',
34
+ positionals: [{ name: 'state', help: 'active | background | inactive (inactive: a host only)', required: true }],
35
+ flags: { app: { type: 'string', help: 'the app to bring back on a simulator or emulator (default: the config’s app)', value: 'bundleId' } },
35
36
  aliases: ['appstate'],
36
37
  needs: 'app',
37
- run: agent('appstate', (ctx) => ({ state: ctx.args[0] })),
38
+ run: async (ctx) => {
39
+ const device = await ctx.device();
40
+ try {
41
+ return { json: await device.command('appstate', { state: ctx.args[0] }) };
42
+ }
43
+ catch (error) {
44
+ // A host stands in for the OS; on a simulator or an emulator the OS itself moves the app.
45
+ if ((await device.resolved()).id === 'headless')
46
+ throw error;
47
+ const done = await simulator('appstate')(ctx);
48
+ // Back in front, the app dials in again; what comes next reads its screen.
49
+ if (ctx.args[0] === 'active')
50
+ await device.server.waitForApp(15_000);
51
+ return done;
52
+ }
53
+ },
38
54
  }),
39
55
  define({
40
56
  name: 'os network',
@@ -61,7 +77,7 @@ export const osCommands = [
61
77
  define({
62
78
  name: 'os biometrics',
63
79
  group: 'os',
64
- summary: 'Headless: the sensor and what the next prompts answer',
80
+ summary: 'The sensor and what the next prompts answer; on a simulator, --available enrolls Face ID or Touch ID or not',
65
81
  flags: {
66
82
  available: { type: 'string', help: 'FaceID | TouchID | none', value: 'kind' },
67
83
  next: { type: 'string', help: 'outcomes of the next prompts, in order', value: 'success,fail,cancel' },
@@ -69,11 +85,28 @@ export const osCommands = [
69
85
  },
70
86
  aliases: ['biometrics'],
71
87
  needs: 'app',
72
- run: agent('biometrics', ({ flags }) => ({
73
- available: flags.available || undefined,
74
- next: flags.next ? flags.next.split(',') : undefined,
75
- fallback: flags.fallback || undefined,
76
- })),
88
+ run: async (ctx) => {
89
+ const device = await ctx.device();
90
+ const params = {
91
+ available: ctx.flags.available || undefined,
92
+ next: ctx.flags.next ? ctx.flags.next.split(',') : undefined,
93
+ fallback: ctx.flags.fallback || undefined,
94
+ };
95
+ try {
96
+ return { json: await device.command('biometrics', params) };
97
+ }
98
+ catch (error) {
99
+ const info = await device.resolved();
100
+ if (info.id === 'headless')
101
+ throw error;
102
+ // A device's prompts answer the person in front of it: only a simulator's enrollment is set from outside.
103
+ // Anything else stays the host's, and the runner says the step cannot run here.
104
+ if (info.platform !== 'ios' || !params.available || params.next || params.fallback)
105
+ throw error;
106
+ ctx.args = [String(params.available)];
107
+ return simulator('biometrics')(ctx);
108
+ }
109
+ },
77
110
  }),
78
111
  define({
79
112
  name: 'os scan',
@@ -102,7 +135,7 @@ export const osCommands = [
102
135
  define({
103
136
  name: 'mock',
104
137
  group: 'os',
105
- summary: 'Headless: answer a request before the network (--sticky survives a reset)',
138
+ summary: 'Answer a request before the network: a host stands in for it, on a device the agent answers in the app (--sticky survives a host reset)',
106
139
  positionals: [{ name: 'route', help: "'GET /path' (a bare path matches any method)" }],
107
140
  flags: {
108
141
  status: { type: 'number', help: 'HTTP status to answer', default: 200, value: 'code' },
@@ -10,6 +10,8 @@ export type Pushed = {
10
10
  };
11
11
  /** What a run changed against the one before, in a line or two for a terminal or a pull request comment. */
12
12
  export declare const describeChanges: (changes: Pushed["changes"]) => string[];
13
+ /** An uncompressed tar: frames are PNG or AVIF, already compressed, and a tar is read as it arrives. */
14
+ export declare const tarOf: (entries: [string, Uint8Array][]) => Uint8Array;
13
15
  /**
14
16
  * Uploads a run directory (`case run --out`) to the reports service: the run, its cases, then every frame and
15
17
  * film the cases name. A file the organization's plan does not keep (a film on Free) is left out and listed.
@@ -19,6 +21,8 @@ export declare const pushRun: (dir: string, o: {
19
21
  key: string;
20
22
  project: string;
21
23
  fetch?: Fetch;
24
+ onReady?: (url: string) => void;
25
+ onProgress?: (line: string) => void;
22
26
  }) => Promise<Pushed>;
23
27
  type Service = {
24
28
  url: string;