lynkeus 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/__tests__/args.test.d.ts +1 -0
- package/lib/__tests__/args.test.js +69 -0
- package/lib/__tests__/check.test.d.ts +1 -0
- package/lib/__tests__/check.test.js +60 -0
- package/lib/__tests__/init.test.d.ts +1 -0
- package/lib/__tests__/init.test.js +42 -0
- package/lib/__tests__/record.test.d.ts +1 -0
- package/lib/__tests__/record.test.js +44 -0
- package/lib/__tests__/snapshot.test.d.ts +1 -0
- package/lib/__tests__/snapshot.test.js +47 -0
- package/lib/__tests__/targets.test.d.ts +1 -0
- package/lib/__tests__/targets.test.js +127 -0
- package/lib/args.d.ts +30 -0
- package/lib/args.js +86 -0
- package/lib/cases/__tests__/format.test.d.ts +1 -0
- package/lib/cases/__tests__/format.test.js +61 -0
- package/lib/cases/__tests__/runner.test.d.ts +1 -0
- package/lib/cases/__tests__/runner.test.js +188 -0
- package/lib/cases/fixtures.d.ts +13 -0
- package/lib/cases/fixtures.js +100 -0
- package/lib/cases/format.d.ts +37 -0
- package/lib/cases/format.js +169 -0
- package/lib/cases/runner.d.ts +61 -0
- package/lib/cases/runner.js +590 -0
- package/lib/cases/templates.d.ts +20 -0
- package/lib/cases/templates.js +156 -0
- package/lib/cli.d.ts +6 -0
- package/lib/cli.js +173 -0
- package/lib/commands/cases.d.ts +1 -0
- package/lib/commands/cases.js +179 -0
- package/lib/commands/check.d.ts +36 -0
- package/lib/commands/check.js +67 -0
- package/lib/commands/crawl/return.d.ts +20 -0
- package/lib/commands/crawl/return.js +125 -0
- package/lib/commands/crawl/state.d.ts +30 -0
- package/lib/commands/crawl/state.js +56 -0
- package/lib/commands/crawl.d.ts +67 -0
- package/lib/commands/crawl.js +213 -0
- package/lib/commands/device.d.ts +10 -0
- package/lib/commands/device.js +122 -0
- package/lib/commands/doctor.d.ts +3 -0
- package/lib/commands/doctor.js +240 -0
- package/lib/commands/drive.d.ts +1 -0
- package/lib/commands/drive.js +410 -0
- package/lib/commands/flake.d.ts +14 -0
- package/lib/commands/flake.js +42 -0
- package/lib/commands/headless.d.ts +1 -0
- package/lib/commands/headless.js +127 -0
- package/lib/commands/init.d.ts +13 -0
- package/lib/commands/init.js +85 -0
- package/lib/commands/map.d.ts +2 -0
- package/lib/commands/map.js +297 -0
- package/lib/commands/os.d.ts +1 -0
- package/lib/commands/os.js +193 -0
- package/lib/commands/overlay.d.ts +9 -0
- package/lib/commands/overlay.js +12 -0
- package/lib/commands/record.d.ts +5 -0
- package/lib/commands/record.js +55 -0
- package/lib/commands/runs.d.ts +1 -0
- package/lib/commands/runs.js +452 -0
- package/lib/commands/smoke.d.ts +28 -0
- package/lib/commands/smoke.js +59 -0
- package/lib/commands/snapshot.d.ts +27 -0
- package/lib/commands/snapshot.js +69 -0
- package/lib/config.d.ts +72 -0
- package/lib/config.js +44 -0
- package/lib/device/devices.d.ts +11 -0
- package/lib/device/devices.js +33 -0
- package/lib/device/driver.d.ts +53 -0
- package/lib/device/driver.js +169 -0
- package/lib/device/platform.d.ts +7 -0
- package/lib/device/platform.js +59 -0
- package/lib/files.d.ts +7 -0
- package/lib/files.js +29 -0
- package/lib/flow/artifacts.d.ts +19 -0
- package/lib/flow/artifacts.js +49 -0
- package/lib/flow/runner.d.ts +13 -0
- package/lib/flow/runner.js +169 -0
- package/lib/flow/types.d.ts +111 -0
- package/lib/flow/types.js +1 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +11 -0
- package/lib/knowledge/__tests__/edges.test.d.ts +1 -0
- package/lib/knowledge/__tests__/edges.test.js +25 -0
- package/lib/knowledge/__tests__/events.test.d.ts +1 -0
- package/lib/knowledge/__tests__/events.test.js +39 -0
- package/lib/knowledge/__tests__/extract.test.d.ts +1 -0
- package/lib/knowledge/__tests__/extract.test.js +69 -0
- package/lib/knowledge/__tests__/frontier.test.d.ts +1 -0
- package/lib/knowledge/__tests__/frontier.test.js +57 -0
- package/lib/knowledge/__tests__/funnels.test.d.ts +1 -0
- package/lib/knowledge/__tests__/funnels.test.js +40 -0
- package/lib/knowledge/__tests__/lint.test.d.ts +1 -0
- package/lib/knowledge/__tests__/lint.test.js +53 -0
- package/lib/knowledge/__tests__/perf.test.d.ts +1 -0
- package/lib/knowledge/__tests__/perf.test.js +34 -0
- package/lib/knowledge/__tests__/plan.test.d.ts +1 -0
- package/lib/knowledge/__tests__/plan.test.js +35 -0
- package/lib/knowledge/__tests__/query.test.d.ts +1 -0
- package/lib/knowledge/__tests__/query.test.js +55 -0
- package/lib/knowledge/__tests__/screens.test.d.ts +1 -0
- package/lib/knowledge/__tests__/screens.test.js +30 -0
- package/lib/knowledge/__tests__/why.test.d.ts +1 -0
- package/lib/knowledge/__tests__/why.test.js +28 -0
- package/lib/knowledge/edges.d.ts +30 -0
- package/lib/knowledge/edges.js +68 -0
- package/lib/knowledge/errors.d.ts +8 -0
- package/lib/knowledge/errors.js +62 -0
- package/lib/knowledge/events.d.ts +23 -0
- package/lib/knowledge/events.js +51 -0
- package/lib/knowledge/extract.d.ts +26 -0
- package/lib/knowledge/extract.js +365 -0
- package/lib/knowledge/frontier.d.ts +26 -0
- package/lib/knowledge/frontier.js +60 -0
- package/lib/knowledge/funnels.d.ts +46 -0
- package/lib/knowledge/funnels.js +103 -0
- package/lib/knowledge/lint.d.ts +16 -0
- package/lib/knowledge/lint.js +89 -0
- package/lib/knowledge/perf.d.ts +32 -0
- package/lib/knowledge/perf.js +91 -0
- package/lib/knowledge/plan.d.ts +22 -0
- package/lib/knowledge/plan.js +81 -0
- package/lib/knowledge/problems.d.ts +5 -0
- package/lib/knowledge/problems.js +8 -0
- package/lib/knowledge/query.d.ts +36 -0
- package/lib/knowledge/query.js +127 -0
- package/lib/knowledge/route-params.d.ts +6 -0
- package/lib/knowledge/route-params.js +42 -0
- package/lib/knowledge/screens.d.ts +30 -0
- package/lib/knowledge/screens.js +125 -0
- package/lib/knowledge/tree.d.ts +15 -0
- package/lib/knowledge/tree.js +63 -0
- package/lib/knowledge/types.d.ts +62 -0
- package/lib/knowledge/types.js +6 -0
- package/lib/knowledge/why.d.ts +24 -0
- package/lib/knowledge/why.js +153 -0
- package/lib/mcp/server.d.ts +4 -0
- package/lib/mcp/server.js +111 -0
- package/lib/registry.d.ts +78 -0
- package/lib/registry.js +113 -0
- package/lib/runtime.d.ts +12 -0
- package/lib/runtime.js +56 -0
- package/lib/targets.d.ts +17 -0
- package/lib/targets.js +43 -0
- package/package.json +47 -0
- package/src/__tests__/args.test.ts +82 -0
- package/src/__tests__/check.test.ts +71 -0
- package/src/__tests__/init.test.ts +52 -0
- package/src/__tests__/record.test.ts +49 -0
- package/src/__tests__/snapshot.test.ts +51 -0
- package/src/__tests__/targets.test.ts +132 -0
- package/src/args.ts +111 -0
- package/src/cases/__tests__/format.test.ts +76 -0
- package/src/cases/__tests__/runner.test.ts +230 -0
- package/src/cases/fixtures.ts +104 -0
- package/src/cases/format.ts +183 -0
- package/src/cases/runner.ts +575 -0
- package/src/cases/templates.ts +159 -0
- package/src/cli.ts +176 -0
- package/src/commands/cases.ts +170 -0
- package/src/commands/check.ts +83 -0
- package/src/commands/crawl/return.ts +130 -0
- package/src/commands/crawl/state.ts +70 -0
- package/src/commands/crawl.ts +262 -0
- package/src/commands/device.ts +119 -0
- package/src/commands/doctor.ts +237 -0
- package/src/commands/drive.ts +413 -0
- package/src/commands/flake.ts +48 -0
- package/src/commands/headless.ts +131 -0
- package/src/commands/init.ts +98 -0
- package/src/commands/map.ts +298 -0
- package/src/commands/os.ts +195 -0
- package/src/commands/overlay.ts +20 -0
- package/src/commands/record.ts +46 -0
- package/src/commands/runs.ts +475 -0
- package/src/commands/smoke.ts +86 -0
- package/src/commands/snapshot.ts +87 -0
- package/src/config.ts +103 -0
- package/src/device/devices.ts +44 -0
- package/src/device/driver.ts +197 -0
- package/src/device/platform.ts +74 -0
- package/src/files.ts +35 -0
- package/src/flow/artifacts.ts +61 -0
- package/src/flow/runner.ts +182 -0
- package/src/flow/types.ts +55 -0
- package/src/index.ts +18 -0
- package/src/knowledge/__tests__/edges.test.ts +34 -0
- package/src/knowledge/__tests__/events.test.ts +45 -0
- package/src/knowledge/__tests__/extract.test.ts +88 -0
- package/src/knowledge/__tests__/frontier.test.ts +77 -0
- package/src/knowledge/__tests__/funnels.test.ts +58 -0
- package/src/knowledge/__tests__/lint.test.ts +66 -0
- package/src/knowledge/__tests__/perf.test.ts +41 -0
- package/src/knowledge/__tests__/plan.test.ts +52 -0
- package/src/knowledge/__tests__/query.test.ts +74 -0
- package/src/knowledge/__tests__/screens.test.ts +37 -0
- package/src/knowledge/__tests__/why.test.ts +40 -0
- package/src/knowledge/edges.ts +91 -0
- package/src/knowledge/errors.ts +73 -0
- package/src/knowledge/events.ts +60 -0
- package/src/knowledge/extract.ts +379 -0
- package/src/knowledge/frontier.ts +82 -0
- package/src/knowledge/funnels.ts +126 -0
- package/src/knowledge/lint.ts +99 -0
- package/src/knowledge/perf.ts +114 -0
- package/src/knowledge/plan.ts +95 -0
- package/src/knowledge/problems.ts +12 -0
- package/src/knowledge/query.ts +146 -0
- package/src/knowledge/route-params.ts +39 -0
- package/src/knowledge/screens.ts +139 -0
- package/src/knowledge/tree.ts +66 -0
- package/src/knowledge/types.ts +60 -0
- package/src/knowledge/why.ts +189 -0
- package/src/mcp/server.ts +109 -0
- package/src/registry.ts +191 -0
- package/src/runtime.ts +54 -0
- package/src/targets.ts +44 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { FlowReport } from '../flow/types.js';
|
|
2
|
+
|
|
3
|
+
export type FlakeSummary = {
|
|
4
|
+
runs: number;
|
|
5
|
+
passed: number;
|
|
6
|
+
msMedian: number;
|
|
7
|
+
failures: { step: number; what: string; error: string; times: number }[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const stepLabel = (r: FlowReport, i: number): string => {
|
|
11
|
+
const s = r.steps[i]?.step;
|
|
12
|
+
if (!s) return '?';
|
|
13
|
+
const [key] = Object.keys(s);
|
|
14
|
+
const v = (s as Record<string, unknown>)[key!];
|
|
15
|
+
return `${key}${typeof v === 'object' && v ? ` ${JSON.stringify(v).slice(0, 60)}` : ''}`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const summarizeRuns = (reports: FlowReport[]): FlakeSummary => {
|
|
19
|
+
const failures = new Map<string, FlakeSummary['failures'][number]>();
|
|
20
|
+
for (const r of reports) {
|
|
21
|
+
if (r.ok) continue;
|
|
22
|
+
const i = r.steps.findIndex((s) => !s.ok);
|
|
23
|
+
const error = (r.steps[i]?.error ?? 'unknown')
|
|
24
|
+
.replace(/\(on [^)]*\)/, '')
|
|
25
|
+
.replace(/\s+/g, ' ')
|
|
26
|
+
.trim()
|
|
27
|
+
.slice(0, 120);
|
|
28
|
+
const key = `${i}|${error}`;
|
|
29
|
+
const f = failures.get(key) ?? { step: i + 1, what: stepLabel(r, i), error, times: 0 };
|
|
30
|
+
f.times += 1;
|
|
31
|
+
failures.set(key, f);
|
|
32
|
+
}
|
|
33
|
+
const ms = reports.map((r) => r.totalMs).sort((a, b) => a - b);
|
|
34
|
+
return {
|
|
35
|
+
runs: reports.length,
|
|
36
|
+
passed: reports.filter((r) => r.ok).length,
|
|
37
|
+
msMedian: ms[Math.floor(ms.length / 2)] ?? 0,
|
|
38
|
+
failures: [...failures.values()].sort((a, b) => b.times - a.times),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const describeFlake = (name: string, s: FlakeSummary): string => {
|
|
43
|
+
const lines = [`${name}: passed ${s.passed}/${s.runs} (median ${Math.round(s.msMedian / 1000)}s)`];
|
|
44
|
+
if (s.passed === s.runs) lines.push(' no failure in this sample; if it fails elsewhere, the difference is the environment (load, data, a parallel runner)');
|
|
45
|
+
for (const f of s.failures) lines.push(` ${f.times}× at step ${f.step} (${f.what}): ${f.error}`);
|
|
46
|
+
if (s.failures.length > 1) lines.push(' more than one failure shape: not one flake but several, or a screen that fails in different ways when slow');
|
|
47
|
+
return lines.join('\n');
|
|
48
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { type LynkeusConfig, lynkeusDir, portFromEnv } from '../config.js';
|
|
6
|
+
import { describeExplanations, explainError } from '../knowledge/errors.js';
|
|
7
|
+
import { type Ctx, define } from '../registry.js';
|
|
8
|
+
import { doctor } from './doctor.js';
|
|
9
|
+
import { init } from './init.js';
|
|
10
|
+
|
|
11
|
+
const ENTRY_FLAG = { entry: { type: 'string' as const, help: 'the headless entry (or `entry` in lynkeus.config.json)', value: 'file' } };
|
|
12
|
+
const entryOf = (ctx: Ctx) => (ctx.flags.entry as string | undefined) ?? ctx.config.entry ?? 'qa/headless.tsx';
|
|
13
|
+
|
|
14
|
+
/** The jest test that hosts the app. The port is written in, not read from the environment there: babel would inline the first run's value into jest's transform cache. */
|
|
15
|
+
const hostTest = (entryImport: string, port: number | undefined, config: LynkeusConfig, appName: string) => {
|
|
16
|
+
const live = config.live?.join('|');
|
|
17
|
+
const options = [
|
|
18
|
+
'navigation: entry.navigation',
|
|
19
|
+
'token: process.env.LYNKEUS_TOKEN',
|
|
20
|
+
`port: ${port ?? 'undefined'}`,
|
|
21
|
+
'host: process.env.LYNKEUS_HOST',
|
|
22
|
+
`live: ${live ? `/${live}/` : 'undefined'}`,
|
|
23
|
+
`strictMode: ${config.strictMode === true}`,
|
|
24
|
+
`app: { name: ${JSON.stringify(appName)}, runtime: 'headless' }`,
|
|
25
|
+
];
|
|
26
|
+
return `// Written by \`lynkeus headless start\`; runs the app under jest as a host for the agent.
|
|
27
|
+
import { hostApp } from 'lynkeus-headless';
|
|
28
|
+
import * as entry from '${entryImport}';
|
|
29
|
+
|
|
30
|
+
hostApp(() => <entry.App />, { ${options.join(', ')} });
|
|
31
|
+
`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const headlessCommands = [
|
|
35
|
+
define({
|
|
36
|
+
name: 'headless start',
|
|
37
|
+
group: 'headless',
|
|
38
|
+
summary: 'Host the app in Node (jest + RNTL) and wait for a driver; same protocol, no simulator',
|
|
39
|
+
details:
|
|
40
|
+
'Needs LYNKEUS_TOKEN in the environment (the value the app reads for its token). LYNKEUS_PORT moves the host and its driver together; LYNKEUS_HOST says where the driver is listening, for a host that is not in the same place as its driver (a container dialling out, say). Arguments after `--` go to jest. When the host crashes, the last lines say what the error usually means.',
|
|
41
|
+
flags: ENTRY_FLAG,
|
|
42
|
+
aliases: ['headless'],
|
|
43
|
+
needs: 'nothing',
|
|
44
|
+
session: false,
|
|
45
|
+
mcp: false,
|
|
46
|
+
run: async (ctx) => {
|
|
47
|
+
const token = process.env.LYNKEUS_TOKEN;
|
|
48
|
+
if (!token)
|
|
49
|
+
return {
|
|
50
|
+
text: 'lynkeus headless needs LYNKEUS_TOKEN in the environment (the same value the app reads for its token): a hosted app talks to a real backend, and without one any local process could drive it.',
|
|
51
|
+
code: 1,
|
|
52
|
+
};
|
|
53
|
+
const entry = entryOf(ctx);
|
|
54
|
+
if (!fs.existsSync(path.join(ctx.root, entry)))
|
|
55
|
+
return { text: `No ${entry} in ${ctx.root}. \`lynkeus headless init\` writes one; it must export \`App\` and, optionally, \`navigation\`.`, code: 1 };
|
|
56
|
+
const testDir = path.join(lynkeusDir(ctx.root), '__tests__');
|
|
57
|
+
const port = portFromEnv(ctx.config);
|
|
58
|
+
// One file per port: two hosts of the same app must not read each other's.
|
|
59
|
+
const testFile = path.join(testDir, port ? `headless-${port}.test.tsx` : 'headless.test.tsx');
|
|
60
|
+
const rel = path
|
|
61
|
+
.relative(testDir, path.join(ctx.root, entry))
|
|
62
|
+
.replace(/\\/g, '/')
|
|
63
|
+
.replace(/\.tsx?$/, '');
|
|
64
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
65
|
+
fs.writeFileSync(testFile, hostTest(rel.startsWith('.') ? rel : `./${rel}`, port, ctx.config, path.basename(ctx.root)));
|
|
66
|
+
// Node resolves `localhost` to ::1 first; local backends usually listen on IPv4 only.
|
|
67
|
+
const nodeOptions = [process.env.NODE_OPTIONS, '--dns-result-order=ipv4first'].filter(Boolean).join(' ');
|
|
68
|
+
const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...ctx.passthrough], {
|
|
69
|
+
cwd: ctx.root,
|
|
70
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
71
|
+
env: { ...process.env, CI: '1', NODE_OPTIONS: nodeOptions },
|
|
72
|
+
});
|
|
73
|
+
let tail = '';
|
|
74
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
75
|
+
process.stderr.write(chunk);
|
|
76
|
+
tail = (tail + chunk.toString()).slice(-20000);
|
|
77
|
+
});
|
|
78
|
+
const code = await new Promise<number>((resolve) => child.on('exit', (c) => resolve(c ?? 0)));
|
|
79
|
+
if (code) {
|
|
80
|
+
const found = explainError(tail);
|
|
81
|
+
if (found.length) ctx.err(describeExplanations(found));
|
|
82
|
+
}
|
|
83
|
+
return { code };
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
define({
|
|
87
|
+
name: 'headless init',
|
|
88
|
+
group: 'headless',
|
|
89
|
+
summary: 'Write the headless entry: default mocks, a local-backend guard, your App',
|
|
90
|
+
flags: {
|
|
91
|
+
...ENTRY_FLAG,
|
|
92
|
+
component: { type: 'string', help: 'the App component file (found by itself when standard)', value: 'App.tsx' },
|
|
93
|
+
'api-env': { type: 'string', help: 'the env var the app reads its API base from', value: 'NAME' },
|
|
94
|
+
auto: { type: 'boolean', help: 'also mount the agent from index.js with auto()' },
|
|
95
|
+
force: { type: 'boolean', help: 'overwrite an existing entry' },
|
|
96
|
+
},
|
|
97
|
+
needs: 'nothing',
|
|
98
|
+
run: async (ctx) => {
|
|
99
|
+
const lines = init(ctx.root, {
|
|
100
|
+
entry: entryOf(ctx),
|
|
101
|
+
app: ctx.flags.component as string | undefined,
|
|
102
|
+
apiEnv: (ctx.flags['api-env'] as string | undefined) ?? ctx.config.apiEnv ?? 'API_URL',
|
|
103
|
+
auto: !!ctx.flags.auto,
|
|
104
|
+
force: !!ctx.flags.force,
|
|
105
|
+
locale: { ...ctx.config.locale, language: ctx.config.language },
|
|
106
|
+
});
|
|
107
|
+
return { text: lines.join('\n'), json: lines };
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
define({
|
|
111
|
+
name: 'headless doctor',
|
|
112
|
+
group: 'headless',
|
|
113
|
+
summary: 'What lynkeus mocks, and which native modules still need one',
|
|
114
|
+
flags: {
|
|
115
|
+
...ENTRY_FLAG,
|
|
116
|
+
fix: { type: 'boolean', help: 'write stubs for the uncovered modules into the entry' },
|
|
117
|
+
explain: { type: 'string', help: 'turn a jest error into its usual cause and fix', value: 'text' },
|
|
118
|
+
},
|
|
119
|
+
needs: 'nothing',
|
|
120
|
+
run: async (ctx) => {
|
|
121
|
+
if (ctx.flags.explain) {
|
|
122
|
+
const found = explainError(ctx.flags.explain as string);
|
|
123
|
+
return {
|
|
124
|
+
text: found.length ? describeExplanations(found).trim() : 'nothing in the catalogue matches; paste the first line of the error, not the stack.',
|
|
125
|
+
json: found,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return doctor(ctx.root, entryOf(ctx), { fix: !!ctx.flags.fix }).join('\n');
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { packageName } from '../config.js';
|
|
5
|
+
|
|
6
|
+
const APP_CANDIDATES = ['App.tsx', 'App.jsx', 'App.js', 'app/App.tsx', 'src/App.tsx', 'src/App.jsx', 'src/App.js'];
|
|
7
|
+
const INDEX_CANDIDATES = ['index.js', 'index.tsx', 'index.ts'];
|
|
8
|
+
|
|
9
|
+
export type InitOptions = {
|
|
10
|
+
entry: string;
|
|
11
|
+
app?: string;
|
|
12
|
+
apiEnv: string;
|
|
13
|
+
auto: boolean;
|
|
14
|
+
force: boolean;
|
|
15
|
+
locale?: { languageTag?: string; country?: string; language?: string };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const findFirst = (root: string, candidates: string[]): string | undefined => candidates.find((c) => fs.existsSync(path.join(root, c)));
|
|
19
|
+
|
|
20
|
+
const relImport = (fromFile: string, toFile: string): string => {
|
|
21
|
+
const rel = path
|
|
22
|
+
.relative(path.dirname(fromFile), toFile)
|
|
23
|
+
.replace(/\\/g, '/')
|
|
24
|
+
.replace(/\.(tsx|ts|jsx|js)$/, '');
|
|
25
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const entryTemplate = (appImport: string, apiEnv: string, appName: string, locale?: InitOptions['locale']): string => {
|
|
29
|
+
const LOCALE =
|
|
30
|
+
locale?.languageTag || locale?.country || locale?.language
|
|
31
|
+
? `\n // The locale this app is built for, from lynkeus.config.json.\n locale: ${JSON.stringify({ country: locale.country, languageTag: locale.languageTag, languageCode: locale.language })},`
|
|
32
|
+
: "\n // locale: { country: 'DE', languageTag: 'de-DE', languageCode: 'de' },";
|
|
33
|
+
return `// Entry for \`lynkeus headless start\`: the whole app, hosted under jest so an agent can
|
|
34
|
+
// drive it in Node against a real local backend. Nothing here runs in the app
|
|
35
|
+
// itself. lynkeus mocks the common native modules (storage, keychain,
|
|
36
|
+
// device-info, safe-area, localize, ...); only ${appName}-specific pieces go here.
|
|
37
|
+
import { installDefaultMocks } from 'lynkeus-headless/mocks'
|
|
38
|
+
|
|
39
|
+
// Real network plus a hosted app is exactly what would let a driver act
|
|
40
|
+
// against production if ${apiEnv} ever pointed there. Refuse anything not local.
|
|
41
|
+
const apiUrl = process.env.${apiEnv} ?? ''
|
|
42
|
+
if (!/^https?:\\/\\/(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2)(:\\d+)?(\\/|$)/.test(apiUrl)) {
|
|
43
|
+
throw new Error(\`lynkeus headless only drives a local backend; ${apiEnv} is "\${apiUrl}"\`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
installDefaultMocks({
|
|
47
|
+
network: 'real',${LOCALE}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// App-specific mocks go here, before the app is required. \`lynkeus headless doctor\`
|
|
51
|
+
// lists the native modules the app references that nothing covers yet:
|
|
52
|
+
// jest.mock('some-native-package', () => ({ method: () => undefined }))
|
|
53
|
+
|
|
54
|
+
// Whatever the app's index runs before rendering (polyfills, i18n, services):
|
|
55
|
+
// require it here too, then the App.
|
|
56
|
+
const { default: App } = require('${appImport}')
|
|
57
|
+
|
|
58
|
+
export { App }
|
|
59
|
+
`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const init = (root: string, opts: InitOptions): string[] => {
|
|
63
|
+
const notes: string[] = [];
|
|
64
|
+
const entryPath = path.join(root, opts.entry);
|
|
65
|
+
const app = opts.app ?? findFirst(root, APP_CANDIDATES);
|
|
66
|
+
if (!app) throw new Error(`no App component found (${APP_CANDIDATES.join(', ')}); pass --component <path>`);
|
|
67
|
+
if (fs.existsSync(entryPath) && !opts.force) {
|
|
68
|
+
notes.push(`${opts.entry} exists; left as is (--force overwrites)`);
|
|
69
|
+
} else {
|
|
70
|
+
fs.mkdirSync(path.dirname(entryPath), { recursive: true });
|
|
71
|
+
fs.writeFileSync(entryPath, entryTemplate(relImport(entryPath, path.join(root, app)), opts.apiEnv, packageName(root), opts.locale));
|
|
72
|
+
notes.push(`wrote ${opts.entry} (app: ${app})`);
|
|
73
|
+
}
|
|
74
|
+
if (opts.auto) {
|
|
75
|
+
const index = findFirst(root, INDEX_CANDIDATES);
|
|
76
|
+
if (!index) {
|
|
77
|
+
notes.push("no index.js to add the auto mount to; add it yourself: import { auto } from 'lynkeus-agent/auto'; auto({ token: ... })");
|
|
78
|
+
} else {
|
|
79
|
+
const indexPath = path.join(root, index);
|
|
80
|
+
const text = fs.readFileSync(indexPath, 'utf8');
|
|
81
|
+
if (text.includes('lynkeus-agent/auto')) {
|
|
82
|
+
notes.push(`${index} already mounts the agent`);
|
|
83
|
+
} else {
|
|
84
|
+
const line =
|
|
85
|
+
"import { auto } from 'lynkeus-agent/auto'\n// Dev-only QA agent; a release build never evaluates the client.\nauto({ token: process.env.LYNKEUS_TOKEN })\n";
|
|
86
|
+
// After the last import, so the app's own polyfills still run first.
|
|
87
|
+
const imports = [...text.matchAll(/^import .*$/gm)];
|
|
88
|
+
const at = imports.length ? imports[imports.length - 1]!.index! + imports[imports.length - 1]![0].length + 1 : 0;
|
|
89
|
+
fs.writeFileSync(indexPath, text.slice(0, at) + line + text.slice(at));
|
|
90
|
+
notes.push(`added auto() to ${index}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
notes.push("jest: make sure transformIgnorePatterns lets 'lynkeus-[^/]+' through, next to react-native (lynkeus headless doctor checks it)");
|
|
95
|
+
notes.push(`next: LYNKEUS_TOKEN=<token> ${opts.apiEnv}=http://127.0.0.1:3000/ lynkeus headless start`);
|
|
96
|
+
notes.push(' then, in another shell: lynkeus screen (or lynkeus headless doctor to see what still needs a mock)');
|
|
97
|
+
return notes;
|
|
98
|
+
};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { edgesFile, frontierFile, graphFile, lynkeusDir } from '../config.js';
|
|
6
|
+
import { readJson, writeJson } from '../files.js';
|
|
7
|
+
import * as edges from '../knowledge/edges.js';
|
|
8
|
+
import * as frontier from '../knowledge/frontier.js';
|
|
9
|
+
import { describePlan, plan } from '../knowledge/plan.js';
|
|
10
|
+
import { type Affected, pathsTo, screensAffectedBy, vocabularyOf } from '../knowledge/query.js';
|
|
11
|
+
import * as screens from '../knowledge/screens.js';
|
|
12
|
+
import type { ScreenGraph } from '../knowledge/types.js';
|
|
13
|
+
import { define } from '../registry.js';
|
|
14
|
+
import { describeGraphDiff, diffGraphs } from './check.js';
|
|
15
|
+
|
|
16
|
+
const baseGraphFile = (root: string) => path.join(lynkeusDir(root), 'graph.base.json');
|
|
17
|
+
|
|
18
|
+
/** Nearest first, under a heading per distance; `chain` names every file in between instead of the last. */
|
|
19
|
+
const describeAffected = (affected: Affected[], chain: boolean): string[] => {
|
|
20
|
+
const lines: string[] = [];
|
|
21
|
+
let last = -1;
|
|
22
|
+
for (const a of affected) {
|
|
23
|
+
if (a.distance !== last) {
|
|
24
|
+
last = a.distance;
|
|
25
|
+
lines.push(a.distance === 0 ? ' changed screens' : a.distance === 1 ? ' screens importing a changed file' : ` ${a.distance} imports away`);
|
|
26
|
+
}
|
|
27
|
+
const how = a.distance === 0 ? '' : a.through.length ? `via ${chain ? a.through.join(' > ') : a.through.at(-1)}` : `imports ${a.via}`;
|
|
28
|
+
lines.push(` ${a.route.padEnd(32)} ${how}`.trimEnd());
|
|
29
|
+
}
|
|
30
|
+
return lines;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const mapCommands = [
|
|
34
|
+
define({
|
|
35
|
+
name: 'map build',
|
|
36
|
+
group: 'map',
|
|
37
|
+
summary: 'Read the source once: routes, testIDs, navigations → .lynkeus/graph.json',
|
|
38
|
+
flags: { 'as-base': { type: 'boolean', help: 'also keep it as graph.base.json, for `map check` to diff against' } },
|
|
39
|
+
needs: 'nothing',
|
|
40
|
+
run: async (ctx) => {
|
|
41
|
+
if (!fs.existsSync(path.join(ctx.root, 'tsconfig.json')))
|
|
42
|
+
return { text: `No tsconfig.json under ${ctx.root}: run this inside a React Native app (or pass --root).`, code: 1 };
|
|
43
|
+
const started = Date.now();
|
|
44
|
+
// The extractor brings a compiler with it; only this command pays for loading it.
|
|
45
|
+
const { extractScreenGraph } = await import('../knowledge/extract.js');
|
|
46
|
+
const graph = extractScreenGraph({ root: ctx.root });
|
|
47
|
+
writeJson(graphFile(ctx.root), graph);
|
|
48
|
+
if (ctx.flags['as-base']) writeJson(baseGraphFile(ctx.root), graph);
|
|
49
|
+
const routes = new Set(Object.keys(graph.screens));
|
|
50
|
+
const dropped = edges.prune(edgesFile(ctx.root), routes) + frontier.prune(frontierFile(ctx.root), routes);
|
|
51
|
+
const facts = Object.values(graph.files);
|
|
52
|
+
const summary = {
|
|
53
|
+
screens: routes.size,
|
|
54
|
+
files: facts.length,
|
|
55
|
+
selectors: facts.reduce((n, f) => n + f.selectors.length, 0),
|
|
56
|
+
edges: facts.reduce((n, f) => n + f.navigates.length, 0),
|
|
57
|
+
seconds: (Date.now() - started) / 1000,
|
|
58
|
+
wrote: graphFile(ctx.root),
|
|
59
|
+
};
|
|
60
|
+
const text = [
|
|
61
|
+
` screens ${summary.screens}`,
|
|
62
|
+
` files ${summary.files}`,
|
|
63
|
+
` selectors ${summary.selectors}`,
|
|
64
|
+
` edges ${summary.edges}`,
|
|
65
|
+
` took ${summary.seconds.toFixed(1)}s`,
|
|
66
|
+
` wrote ${summary.wrote}`,
|
|
67
|
+
...(dropped ? [` collected ${dropped} rows for screens that are gone`] : []),
|
|
68
|
+
];
|
|
69
|
+
return { text: text.join('\n'), json: { ...summary, dropped } };
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
define({
|
|
73
|
+
name: 'map screens',
|
|
74
|
+
group: 'map',
|
|
75
|
+
summary: 'Every route in the graph, and every route a run has actually opened',
|
|
76
|
+
needs: 'graph',
|
|
77
|
+
run: async (ctx) => {
|
|
78
|
+
const fromSource = new Set(Object.keys(ctx.graph().screens));
|
|
79
|
+
const seen = screens.observed(lynkeusDir(ctx.root));
|
|
80
|
+
const rows = [...new Set([...fromSource, ...seen.keys()])]
|
|
81
|
+
.sort()
|
|
82
|
+
.map((route) => ({ route, source: fromSource.has(route), seen: seen.get(route)?.seen ?? 0 }));
|
|
83
|
+
const text = rows.map((r) => `${r.route}${r.source ? '' : ' (observed only)'}${r.seen ? ` seen ${r.seen}x` : ''}`).join('\n');
|
|
84
|
+
return { text, json: rows };
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
define({
|
|
88
|
+
name: 'map show',
|
|
89
|
+
mcp: 'core',
|
|
90
|
+
group: 'map',
|
|
91
|
+
summary: 'testIDs and navigations a screen is likely to have, from source',
|
|
92
|
+
positionals: [{ name: 'route', help: 'e.g. Settings.Main', required: true }],
|
|
93
|
+
needs: 'graph',
|
|
94
|
+
run: async (ctx) => {
|
|
95
|
+
const route = ctx.args[0]!;
|
|
96
|
+
const v = vocabularyOf(ctx.graph(), route);
|
|
97
|
+
const seen = screens.observed(lynkeusDir(ctx.root)).get(route);
|
|
98
|
+
if (!v && !seen) return { text: `Unknown route ${route}`, code: 1 };
|
|
99
|
+
// Observed first: a testID read off the running app is a fact; one from source is a guess about which screen renders it.
|
|
100
|
+
const observedIds = seen?.testIds ?? [];
|
|
101
|
+
const fromSource = new Set((v?.selectors ?? []).map((s) => s.id));
|
|
102
|
+
const sourceOnly = (v?.selectors ?? []).filter((s) => !observedIds.includes(s.id));
|
|
103
|
+
const navigatesTo = v?.navigatesTo ?? [];
|
|
104
|
+
const lines = [
|
|
105
|
+
`${route} (${v?.file ?? 'not in the source graph'})`,
|
|
106
|
+
seen ? `seen ${seen.seen}x, last ${seen.date}${seen.path.length ? ` [${seen.path.join(' > ')}]` : ''}` : 'never opened by a run',
|
|
107
|
+
'',
|
|
108
|
+
'selectors:',
|
|
109
|
+
...observedIds.map((id) => ` #${id}${fromSource.has(id) ? '' : ' (observed)'}`),
|
|
110
|
+
...sourceOnly.map((s) => ` #${s.id} (source only, not seen)`),
|
|
111
|
+
...(observedIds.length + sourceOnly.length === 0 ? [' (none)'] : []),
|
|
112
|
+
'',
|
|
113
|
+
'navigates to:',
|
|
114
|
+
...navigatesTo.map((e) => ` ${e.to} ${e.via.kind === 'testId' ? `via #${e.via.testId}` : `(${e.via.kind})`}`),
|
|
115
|
+
...(navigatesTo.length === 0 ? [' (none)'] : []),
|
|
116
|
+
];
|
|
117
|
+
return {
|
|
118
|
+
text: lines.join('\n'),
|
|
119
|
+
json: {
|
|
120
|
+
...(v ?? { route, file: null, selectors: [], navigatesTo: [], filesConsidered: 0 }),
|
|
121
|
+
observed: seen ?? null,
|
|
122
|
+
observedOnly: observedIds.filter((id) => !fromSource.has(id)),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
}),
|
|
127
|
+
define({
|
|
128
|
+
name: 'map paths',
|
|
129
|
+
group: 'map',
|
|
130
|
+
summary: 'What leads to a route: observed edges first, then static',
|
|
131
|
+
positionals: [{ name: 'route', help: 'e.g. Checkout.Confirm', required: true }],
|
|
132
|
+
needs: 'graph',
|
|
133
|
+
run: async (ctx) => {
|
|
134
|
+
const list = pathsTo(ctx.graph(), ctx.args[0]!, edges.load(edgesFile(ctx.root)));
|
|
135
|
+
const text = list.map((a) => ` ${a.source.padEnd(8)} ${a.via.padEnd(40)} from ${a.from}${a.seen ? ` (seen ${a.seen}×)` : ''}`).join('\n');
|
|
136
|
+
return { text: text || '(nothing known)', json: list };
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
define({
|
|
140
|
+
name: 'map plan',
|
|
141
|
+
mcp: 'core',
|
|
142
|
+
group: 'map',
|
|
143
|
+
summary: 'Directions from one route to another: which buttons to press, screen by screen',
|
|
144
|
+
flags: {
|
|
145
|
+
from: { type: 'string', help: 'starting route', value: 'Route' },
|
|
146
|
+
to: { type: 'string', help: 'destination route', value: 'Route' },
|
|
147
|
+
},
|
|
148
|
+
aliases: ['plan'],
|
|
149
|
+
needs: 'graph',
|
|
150
|
+
run: async (ctx) => {
|
|
151
|
+
const from = ctx.flags.from as string | undefined;
|
|
152
|
+
const to = ctx.flags.to as string | undefined;
|
|
153
|
+
if (!from || !to) throw new Error('map plan needs --from <Route> --to <Route>');
|
|
154
|
+
const steps = plan(ctx.graph(), from, to, edges.load(edgesFile(ctx.root)));
|
|
155
|
+
if (!steps) return { text: `No known path from ${from} to ${to}. Crawl to learn more edges.`, code: 1 };
|
|
156
|
+
return { text: steps.length ? describePlan(steps) : 'Already there.', json: steps };
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
define({
|
|
160
|
+
name: 'map impact',
|
|
161
|
+
group: 'map',
|
|
162
|
+
summary: 'Screens a change can reach, nearest first',
|
|
163
|
+
positionals: [{ name: 'file', help: 'changed source files', required: true, rest: true }],
|
|
164
|
+
flags: {
|
|
165
|
+
depth: { type: 'number', help: 'how many imports away to look', default: 4, value: 'n' },
|
|
166
|
+
direct: { type: 'boolean', help: 'only the screen and its importers' },
|
|
167
|
+
},
|
|
168
|
+
needs: 'graph',
|
|
169
|
+
run: async (ctx) => {
|
|
170
|
+
const depth = ctx.flags.depth as number;
|
|
171
|
+
const all = screensAffectedBy(ctx.graph(), ctx.args, depth);
|
|
172
|
+
const affected = ctx.flags.direct ? all.filter((a) => a.distance <= 1) : all;
|
|
173
|
+
if (affected.length === 0) {
|
|
174
|
+
const text =
|
|
175
|
+
all.length === 0
|
|
176
|
+
? ` (no screen imports these files, directly or through ${depth} levels)`
|
|
177
|
+
: ` (nothing within one import; ${all.length} screens further away, a shared module. Drop --direct to list them.)`;
|
|
178
|
+
return { text, json: affected };
|
|
179
|
+
}
|
|
180
|
+
const lines = describeAffected(affected, false);
|
|
181
|
+
const far = affected.filter((a) => a.distance > 1).length;
|
|
182
|
+
if (far > 20) lines.push(` (${far} screens more than one import away, a shared module; --direct narrows to what to test first)`);
|
|
183
|
+
return { text: lines.join('\n'), json: affected };
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
define({
|
|
187
|
+
name: 'map ci',
|
|
188
|
+
group: 'map',
|
|
189
|
+
summary: 'Write a GitHub workflow that comments on each pull request which screens its diff reaches',
|
|
190
|
+
details:
|
|
191
|
+
'Writes `.github/workflows/lynkeus-map-check.yml` in the app. On every pull request it builds the map from the sources (no app, no device, well under a second of analysis), runs `map check` against the base branch and keeps one comment on the PR up to date. `--force` overwrites an existing file.',
|
|
192
|
+
flags: {
|
|
193
|
+
force: { type: 'boolean', help: 'overwrite the workflow if it exists' },
|
|
194
|
+
depth: { type: 'number', help: 'how many imports away the check looks', default: 2, value: 'n' },
|
|
195
|
+
},
|
|
196
|
+
needs: 'nothing',
|
|
197
|
+
run: async (ctx) => {
|
|
198
|
+
const file = path.join(ctx.root, '.github', 'workflows', 'lynkeus-map-check.yml');
|
|
199
|
+
if (fs.existsSync(file) && !ctx.flags.force) return { text: `${path.relative(ctx.root, file)} exists; --force overwrites it`, code: 1 };
|
|
200
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
201
|
+
fs.writeFileSync(file, mapCheckWorkflow(ctx.flags.depth as number));
|
|
202
|
+
return { text: `wrote ${path.relative(ctx.root, file)}`, json: { file } };
|
|
203
|
+
},
|
|
204
|
+
}),
|
|
205
|
+
define({
|
|
206
|
+
name: 'map check',
|
|
207
|
+
group: 'map',
|
|
208
|
+
summary: "A branch from the app's side: screens its diff reaches, routes/navigations/testIDs that changed",
|
|
209
|
+
flags: {
|
|
210
|
+
base: { type: 'string', help: 'git ref to diff against', default: 'main', value: 'ref' },
|
|
211
|
+
'graph-base': { type: 'string', help: 'graph to diff against (default .lynkeus/graph.base.json)', value: 'file' },
|
|
212
|
+
depth: { type: 'number', help: 'how many imports away to look', default: 4, value: 'n' },
|
|
213
|
+
},
|
|
214
|
+
needs: 'graph',
|
|
215
|
+
run: async (ctx) => {
|
|
216
|
+
const graph = ctx.graph();
|
|
217
|
+
const base = ctx.flags.base as string;
|
|
218
|
+
const git = (...args: string[]) => execFileSync('git', args, { cwd: ctx.root, encoding: 'utf8' });
|
|
219
|
+
let changed: string[] = [];
|
|
220
|
+
try {
|
|
221
|
+
const names = `${git('diff', '--name-only', `${base}...HEAD`)}\n${git('diff', '--name-only', 'HEAD')}`;
|
|
222
|
+
changed = [
|
|
223
|
+
...new Set(
|
|
224
|
+
names
|
|
225
|
+
.split('\n')
|
|
226
|
+
.map((l) => l.trim())
|
|
227
|
+
.filter((l) => /\.[cm]?[jt]sx?$/.test(l)),
|
|
228
|
+
),
|
|
229
|
+
];
|
|
230
|
+
} catch (error) {
|
|
231
|
+
ctx.err(`could not diff against ${base}: ${error instanceof Error ? error.message : String(error)}`);
|
|
232
|
+
}
|
|
233
|
+
const affected = screensAffectedBy(graph, changed, ctx.flags.depth as number);
|
|
234
|
+
const lines = [`changed against ${base}: ${changed.length} source file(s)`, ...describeAffected(affected, true)];
|
|
235
|
+
if (!affected.length && changed.length) lines.push(' (no screen imports these files within the depth)');
|
|
236
|
+
const baseFile = (ctx.flags['graph-base'] as string | undefined) ?? baseGraphFile(ctx.root);
|
|
237
|
+
const graphDiff = fs.existsSync(baseFile) ? diffGraphs(readJson<ScreenGraph>(baseFile), graph) : undefined;
|
|
238
|
+
if (graphDiff) lines.push(`graph against ${path.relative(ctx.root, baseFile)}:`, describeGraphDiff(graphDiff));
|
|
239
|
+
else lines.push(`graph: no baseline (run \`lynkeus map build --as-base\` on ${base} to diff routes, navigations and testIDs)`);
|
|
240
|
+
if (affected.length) {
|
|
241
|
+
const first = affected.slice(0, 5).map((a) => a.route);
|
|
242
|
+
lines.push(
|
|
243
|
+
`next: run the cases that cover ${first.join(', ')}${affected.length > 5 ? ', …' : ''}; \`lynkeus lint --baseline <file>\` on those screens prints what is new`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return { text: lines.join('\n'), json: { base, changed, affected, graphDiff } };
|
|
247
|
+
},
|
|
248
|
+
}),
|
|
249
|
+
define({
|
|
250
|
+
name: 'map changes',
|
|
251
|
+
group: 'map',
|
|
252
|
+
summary: 'testIDs that appeared, disappeared or moved on a recorded screen since it was last read',
|
|
253
|
+
flags: { last: { type: 'number', help: 'how many changes', default: 30, value: 'n' } },
|
|
254
|
+
needs: 'nothing',
|
|
255
|
+
run: async (ctx) => {
|
|
256
|
+
const list = screens.changes(lynkeusDir(ctx.root), ctx.flags.last as number);
|
|
257
|
+
if (list.length === 0) return { text: '(no recorded screen changes; record with `lynkeus screen --record` or LYNKEUS_RECORD_SCREENS=1)', json: [] };
|
|
258
|
+
return { text: list.map(screens.describeChange).join('\n'), json: list };
|
|
259
|
+
},
|
|
260
|
+
}),
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
export const mapCheckWorkflow = (depth: number): string => `name: lynkeus map check
|
|
264
|
+
on:
|
|
265
|
+
pull_request:
|
|
266
|
+
|
|
267
|
+
permissions:
|
|
268
|
+
contents: read
|
|
269
|
+
pull-requests: write
|
|
270
|
+
|
|
271
|
+
jobs:
|
|
272
|
+
map-check:
|
|
273
|
+
runs-on: ubuntu-latest
|
|
274
|
+
steps:
|
|
275
|
+
- uses: actions/checkout@v5
|
|
276
|
+
with:
|
|
277
|
+
fetch-depth: 0
|
|
278
|
+
- uses: actions/setup-node@v5
|
|
279
|
+
with:
|
|
280
|
+
node-version: 22
|
|
281
|
+
- name: Build the map and check the diff
|
|
282
|
+
run: |
|
|
283
|
+
npx --yes lynkeus map build > /dev/null
|
|
284
|
+
npx --yes lynkeus map check --base origin/\${{ github.base_ref }} --depth ${depth} > map-check.txt
|
|
285
|
+
- name: Comment on the pull request
|
|
286
|
+
env:
|
|
287
|
+
GH_TOKEN: \${{ github.token }}
|
|
288
|
+
run: |
|
|
289
|
+
{
|
|
290
|
+
echo '<!-- lynkeus map check -->'
|
|
291
|
+
echo '**Screens this diff reaches** (lynkeus map check)'
|
|
292
|
+
echo
|
|
293
|
+
echo '\`\`\`'
|
|
294
|
+
cat map-check.txt
|
|
295
|
+
echo '\`\`\`'
|
|
296
|
+
} > body.md
|
|
297
|
+
gh pr comment \${{ github.event.pull_request.number }} --edit-last --body-file body.md || gh pr comment \${{ github.event.pull_request.number }} --body-file body.md
|
|
298
|
+
`;
|