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
package/lib/registry.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { GLOBAL_FLAGS, parseArgs } from './args.js';
|
|
2
|
+
const GROUPS = {
|
|
3
|
+
drive: { title: 'Drive', blurb: 'the app that is running, through the agent inside it' },
|
|
4
|
+
os: { title: 'os', blurb: 'what the outside world hands the app: links, clock, sensors, the network, the simulator' },
|
|
5
|
+
map: { title: 'map', blurb: "what lynkeus knows before touching anything, from the app's source and from walks" },
|
|
6
|
+
runs: { title: 'Runs', blurb: 'flows, smokes, crawls and recordings' },
|
|
7
|
+
headless: { title: 'headless', blurb: 'the app hosted in Node under jest, no simulator' },
|
|
8
|
+
};
|
|
9
|
+
export const define = (c) => c;
|
|
10
|
+
const byName = new Map();
|
|
11
|
+
const all = [];
|
|
12
|
+
export const register = (commands) => {
|
|
13
|
+
for (const c of commands) {
|
|
14
|
+
for (const name of [c.name, ...(c.aliases ?? [])]) {
|
|
15
|
+
if (byName.has(name))
|
|
16
|
+
throw new Error(`${name} is defined twice`);
|
|
17
|
+
byName.set(name, c);
|
|
18
|
+
}
|
|
19
|
+
all.push(c);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
export const commands = () => all;
|
|
23
|
+
export const find = (name) => byName.get(name);
|
|
24
|
+
/** The command an argv names, and the rest of the argv. */
|
|
25
|
+
export const resolve = (argv) => {
|
|
26
|
+
const two = argv.length >= 2 ? byName.get(argv.slice(0, 2).join(' ')) : undefined;
|
|
27
|
+
if (two)
|
|
28
|
+
return { command: two, rest: argv.slice(2) };
|
|
29
|
+
const one = argv[0] ? byName.get(argv[0]) : undefined;
|
|
30
|
+
return one ? { command: one, rest: argv.slice(1) } : undefined;
|
|
31
|
+
};
|
|
32
|
+
export const parseFor = (command, rest) => parseArgs(rest, command.positionals, command.flags);
|
|
33
|
+
export const inSession = (c) => c.session ?? c.needs !== 'nothing';
|
|
34
|
+
export const overMcp = (c, full) => {
|
|
35
|
+
if (c.mcp === false || (c.needs === 'nothing' && c.mcp === undefined))
|
|
36
|
+
return false;
|
|
37
|
+
return c.mcp === 'core' || full;
|
|
38
|
+
};
|
|
39
|
+
const flagSignature = (name, f) => `--${name}${f.type === 'boolean' ? '' : ` <${f.value ?? f.type}>`}`;
|
|
40
|
+
const signature = (c) => {
|
|
41
|
+
const pos = (c.positionals ?? []).map((p) => (p.required ? `<${p.name}>` : `[${p.name}]`) + (p.rest ? '…' : '')).join(' ');
|
|
42
|
+
const fl = Object.entries(c.flags ?? {})
|
|
43
|
+
.map(([n, f]) => `[${flagSignature(n, f)}]`)
|
|
44
|
+
.join(' ');
|
|
45
|
+
return [c.name, pos, fl].filter(Boolean).join(' ');
|
|
46
|
+
};
|
|
47
|
+
const inGroups = () => Object.keys(GROUPS).map((g) => ({ ...GROUPS[g], list: all.filter((c) => c.group === g) })).filter((g) => g.list.length);
|
|
48
|
+
export const usage = () => {
|
|
49
|
+
const lines = ['lynkeus — drive a React Native app from the outside, through the agent inside it', ''];
|
|
50
|
+
for (const g of inGroups()) {
|
|
51
|
+
lines.push(`${g.title} — ${g.blurb}`);
|
|
52
|
+
for (const c of g.list) {
|
|
53
|
+
const sig = c.example ?? signature(c);
|
|
54
|
+
lines.push(sig.length > 44 ? ` ${sig}\n${' '.repeat(47)}${c.summary}` : ` ${sig.padEnd(45)}${c.summary}`);
|
|
55
|
+
}
|
|
56
|
+
lines.push('');
|
|
57
|
+
}
|
|
58
|
+
lines.push(`Global: ${Object.entries(GLOBAL_FLAGS)
|
|
59
|
+
.map(([n, f]) => flagSignature(n, f))
|
|
60
|
+
.join(' ')}`, '`lynkeus help <command>` for details; `lynkeus session` runs many commands on one connection; `lynkeus mcp` serves the core of them to an AI harness (`--full`: all).');
|
|
61
|
+
return lines.join('\n');
|
|
62
|
+
};
|
|
63
|
+
export const helpFor = (c) => {
|
|
64
|
+
const lines = [`lynkeus ${signature(c)}`, '', c.summary];
|
|
65
|
+
if (c.details)
|
|
66
|
+
lines.push('', c.details);
|
|
67
|
+
if (c.positionals?.length) {
|
|
68
|
+
lines.push('', 'Arguments');
|
|
69
|
+
for (const p of c.positionals)
|
|
70
|
+
lines.push(` ${p.name.padEnd(14)} ${p.help}${p.required ? '' : ' (optional)'}`);
|
|
71
|
+
}
|
|
72
|
+
const flags = Object.entries(c.flags ?? {});
|
|
73
|
+
if (flags.length) {
|
|
74
|
+
lines.push('', 'Flags');
|
|
75
|
+
for (const [n, f] of flags)
|
|
76
|
+
lines.push(` ${flagSignature(n, f).padEnd(24)} ${f.help}${f.default !== undefined ? ` (default ${f.default})` : ''}`);
|
|
77
|
+
}
|
|
78
|
+
if (c.aliases?.length)
|
|
79
|
+
lines.push('', `Also: ${c.aliases.join(', ')}`);
|
|
80
|
+
const needs = c.needs === 'app' ? 'the app running with the agent' : c.needs === 'graph' ? 'a graph (lynkeus map build)' : 'nothing';
|
|
81
|
+
const mcp = c.mcp === 'core' ? '; served over MCP' : overMcp(c, true) ? '; served over MCP with --full' : '';
|
|
82
|
+
lines.push('', `Needs: ${needs}${inSession(c) ? '; available in a session' : ''}${mcp}`);
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
};
|
|
85
|
+
const flagRow = (n, f) => `| \`${flagSignature(n, f)}\` | ${f.help}${f.default !== undefined ? ` (default \`${f.default}\`)` : ''} |`;
|
|
86
|
+
/** The reference document, generated so it cannot drift from the commands. */
|
|
87
|
+
export const referenceMarkdown = () => {
|
|
88
|
+
const lines = [
|
|
89
|
+
'# lynkeus command reference',
|
|
90
|
+
'',
|
|
91
|
+
'Generated from the command registry (`lynkeus help --markdown`). Every command accepts the global flags at the end.',
|
|
92
|
+
'',
|
|
93
|
+
];
|
|
94
|
+
for (const g of inGroups()) {
|
|
95
|
+
lines.push(`## ${g.title}`, '', `${g.blurb}.`, '');
|
|
96
|
+
for (const c of g.list) {
|
|
97
|
+
lines.push(`### \`lynkeus ${signature(c)}\``, '', c.summary + (c.details ? `\n\n${c.details}` : ''), '');
|
|
98
|
+
if (c.positionals?.length) {
|
|
99
|
+
lines.push('| Argument | |', '|---|---|');
|
|
100
|
+
for (const p of c.positionals)
|
|
101
|
+
lines.push(`| \`${p.name}\` | ${p.help}${p.required ? '' : ' (optional)'} |`);
|
|
102
|
+
lines.push('');
|
|
103
|
+
}
|
|
104
|
+
const flags = Object.entries(c.flags ?? {});
|
|
105
|
+
if (flags.length)
|
|
106
|
+
lines.push('| Flag | |', '|---|---|', ...flags.map(([n, f]) => flagRow(n, f)), '');
|
|
107
|
+
if (c.aliases?.length)
|
|
108
|
+
lines.push(`Also accepted: ${c.aliases.map((a) => `\`${a}\``).join(', ')}.`, '');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
lines.push('## Modes', '', '`lynkeus session` keeps one connection and reads JSON arrays of arguments on stdin, one command per line, answering one JSON object per line (`{ok, out, json?}` or `{ok:false, error, why?}`); `exit` ends it. `lynkeus mcp` serves the core commands as MCP tools over stdio; `lynkeus mcp --full` serves all of them. `lynkeus help <command>` prints one command; `lynkeus help --markdown` prints this document.', '', '## Global flags', '', '| Flag | |', '|---|---|', ...Object.entries(GLOBAL_FLAGS).map(([n, f]) => flagRow(n, f)), '');
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
};
|
package/lib/runtime.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Base } from './registry.js';
|
|
2
|
+
import { type Expected } from './targets.js';
|
|
3
|
+
export type Outcome = {
|
|
4
|
+
text?: string;
|
|
5
|
+
json?: unknown;
|
|
6
|
+
code: number;
|
|
7
|
+
error?: string;
|
|
8
|
+
why?: string;
|
|
9
|
+
};
|
|
10
|
+
/** The screen, requests and trace after a failure, read as rules; empty when there is nothing to read. */
|
|
11
|
+
export declare const whyFor: (base: Base, expected?: Expected) => Promise<string>;
|
|
12
|
+
export declare const execute: (argv: string[], base: Base) => Promise<Outcome>;
|
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one command from an argv: resolve, parse, run, and on failure read the
|
|
3
|
+
* evidence. The terminal, session lines, a recording's stdin, cases and the
|
|
4
|
+
* MCP server all come through here.
|
|
5
|
+
*/
|
|
6
|
+
import { UsageError } from './args.js';
|
|
7
|
+
import { describeWhy, explain } from './knowledge/why.js';
|
|
8
|
+
import { helpFor, parseFor, resolve } from './registry.js';
|
|
9
|
+
import { expectedOf } from './targets.js';
|
|
10
|
+
/** The screen, requests and trace after a failure, read as rules; empty when there is nothing to read. */
|
|
11
|
+
export const whyFor = async (base, expected) => {
|
|
12
|
+
try {
|
|
13
|
+
const device = await base.device();
|
|
14
|
+
const [screen, requests, trace] = await Promise.all([device.screen(), device.requests(), device.trace()]);
|
|
15
|
+
return describeWhy(explain({ screen, requests: requests.requests.slice(-40), inFlight: requests.inFlight, trace: trace.events.slice(-40), target: expected }));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const TARGET_ARG = { press: 0, see: 0, wait: 0, swipe: 1, nav: 0, type: 1 };
|
|
22
|
+
export const execute = async (argv, base) => {
|
|
23
|
+
const found = resolve(argv);
|
|
24
|
+
if (!found)
|
|
25
|
+
return { code: 2, error: `unknown command "${argv.slice(0, 2).join(' ')}"; \`lynkeus help\` lists them` };
|
|
26
|
+
const { command, rest } = found;
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = parseFor(command, rest);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof UsageError)
|
|
33
|
+
return { code: 2, error: `${error.message}\n\n${helpFor(command)}` };
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
if (parsed.flags.help)
|
|
37
|
+
return { code: 0, text: helpFor(command) };
|
|
38
|
+
try {
|
|
39
|
+
const result = await command.run({ ...base, ...parsed });
|
|
40
|
+
if (result === undefined)
|
|
41
|
+
return { code: 0 };
|
|
42
|
+
if (typeof result === 'string')
|
|
43
|
+
return { code: 0, text: result };
|
|
44
|
+
return { code: result.code ?? 0, text: result.text ?? (result.json === undefined ? undefined : JSON.stringify(result.json)), json: result.json };
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const out = { code: 1, error: error instanceof Error ? error.message : String(error) };
|
|
48
|
+
const at = TARGET_ARG[command.name];
|
|
49
|
+
if (at !== undefined) {
|
|
50
|
+
const why = await whyFor(base, expectedOf(parsed.args[at]));
|
|
51
|
+
if (why)
|
|
52
|
+
out.why = why;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
};
|
package/lib/targets.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Target } from 'lynkeus-protocol';
|
|
2
|
+
export declare const TARGET_HELP = "#testId | \"text\" | 12 (index) | 100,200";
|
|
3
|
+
/** `#testId`, `12` (an index), `100,200` (a point), anything else a text; `#testId[2]` or `text[2]` is the third match. */
|
|
4
|
+
export declare const parseTarget: (raw: string) => Target;
|
|
5
|
+
/** A target narrowed to the match nearest another (`--in`) and to a position among the matches (`--nth`). */
|
|
6
|
+
export declare const scopedTarget: (raw: string, flags: {
|
|
7
|
+
in?: unknown;
|
|
8
|
+
nth?: unknown;
|
|
9
|
+
}) => Target;
|
|
10
|
+
export declare const isRoute: (raw: string) => boolean;
|
|
11
|
+
export type Expected = {
|
|
12
|
+
testId?: string;
|
|
13
|
+
text?: string;
|
|
14
|
+
route?: string;
|
|
15
|
+
};
|
|
16
|
+
/** What a command was looking for, as `why` reads it. */
|
|
17
|
+
export declare const expectedOf: (raw: string | undefined) => Expected | undefined;
|
package/lib/targets.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const TARGET_HELP = '#testId | "text" | 12 (index) | 100,200';
|
|
2
|
+
/** `#testId`, `12` (an index), `100,200` (a point), anything else a text; `#testId[2]` or `text[2]` is the third match. */
|
|
3
|
+
export const parseTarget = (raw) => {
|
|
4
|
+
const nth = /^(.+)\[(\d+)\]$/.exec(raw);
|
|
5
|
+
if (nth && !/^\d+$/.test(nth[1])) {
|
|
6
|
+
const base = parseTarget(nth[1]);
|
|
7
|
+
if ('testId' in base || 'text' in base)
|
|
8
|
+
return { ...base, nth: Number(nth[2]) };
|
|
9
|
+
}
|
|
10
|
+
if (raw.startsWith('#'))
|
|
11
|
+
return { testId: raw.slice(1) };
|
|
12
|
+
if (/^\d+,\d+$/.test(raw)) {
|
|
13
|
+
const [x, y] = raw.split(',').map(Number);
|
|
14
|
+
return { x: x, y: y };
|
|
15
|
+
}
|
|
16
|
+
if (/^\d+$/.test(raw))
|
|
17
|
+
return { i: Number(raw) };
|
|
18
|
+
return { text: raw };
|
|
19
|
+
};
|
|
20
|
+
/** A target narrowed to the match nearest another (`--in`) and to a position among the matches (`--nth`). */
|
|
21
|
+
export const scopedTarget = (raw, flags) => {
|
|
22
|
+
const target = parseTarget(raw);
|
|
23
|
+
if (!('testId' in target) && !('text' in target))
|
|
24
|
+
return target;
|
|
25
|
+
const scope = typeof flags.in === 'string' ? parseTarget(flags.in) : undefined;
|
|
26
|
+
if (scope && !('testId' in scope) && !('text' in scope))
|
|
27
|
+
throw new Error('--in takes a #testId or a text');
|
|
28
|
+
return {
|
|
29
|
+
...target,
|
|
30
|
+
...(scope ? { within: 'testId' in scope ? { testId: scope.testId } : { text: scope.text } } : {}),
|
|
31
|
+
...(typeof flags.nth === 'number' ? { nth: flags.nth } : {}),
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
export const isRoute = (raw) => /^[A-Z]\w*(\.[A-Z]\w*)*$/.test(raw);
|
|
35
|
+
/** What a command was looking for, as `why` reads it. */
|
|
36
|
+
export const expectedOf = (raw) => {
|
|
37
|
+
if (!raw)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (isRoute(raw))
|
|
40
|
+
return { route: raw };
|
|
41
|
+
const t = parseTarget(raw);
|
|
42
|
+
return 'testId' in t ? { testId: t.testId } : 'text' in t ? { text: t.text } : undefined;
|
|
43
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lynkeus",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Drive a React Native app running lynkeus-agent from the terminal, a test runner or an MCP client: screens, flows, smoke, crawl, and the app's screen graph.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": "./lib/cli.js",
|
|
7
|
+
"main": "./lib/index.js",
|
|
8
|
+
"types": "./lib/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./lib/index.d.ts",
|
|
12
|
+
"default": "./lib/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"lib",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
22
|
+
"test": "node --import tsx --test src/__tests__/*.test.ts src/knowledge/__tests__/*.test.ts src/cases/__tests__/*.test.ts",
|
|
23
|
+
"dev": "tsx src/cli.ts",
|
|
24
|
+
"docs": "node lib/cli.js help --markdown > ../../docs/COMMANDS.md"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/jonamischener/lynkeus.git",
|
|
29
|
+
"directory": "packages/cli"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
34
|
+
"lynkeus-client": "^0.2.0",
|
|
35
|
+
"lynkeus-protocol": "^0.2.0",
|
|
36
|
+
"pngjs": "^7.0.0",
|
|
37
|
+
"ts-morph": "^24.0.0",
|
|
38
|
+
"yaml": "^2"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.10.2",
|
|
42
|
+
"@types/pngjs": "^6.0.5",
|
|
43
|
+
"tsx": "^4.19.2",
|
|
44
|
+
"typescript": "^6.0.3"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/jonamischener/lynkeus#readme"
|
|
47
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { pairs, parseArgs, splitLine, UsageError } from '../args.js';
|
|
5
|
+
import { driveCommands } from '../commands/drive.js';
|
|
6
|
+
import { headlessCommands } from '../commands/headless.js';
|
|
7
|
+
import { mapCommands } from '../commands/map.js';
|
|
8
|
+
import { osCommands } from '../commands/os.js';
|
|
9
|
+
import { runsCommands } from '../commands/runs.js';
|
|
10
|
+
|
|
11
|
+
const FLAGS = {
|
|
12
|
+
direct: { type: 'boolean' as const, help: '' },
|
|
13
|
+
depth: { type: 'number' as const, help: '', default: 4 },
|
|
14
|
+
var: { type: 'list' as const, help: '' },
|
|
15
|
+
out: { type: 'string' as const, help: '' },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
test('booleans never eat the next positional; numbers are numbers; lists collect', () => {
|
|
19
|
+
const p = parseArgs(
|
|
20
|
+
['--direct', 'a.ts', 'b.ts', '--depth', '2', '--var', 'x=1', '--var', 'y=2'],
|
|
21
|
+
[{ name: 'file', help: '', required: true, rest: true }],
|
|
22
|
+
FLAGS,
|
|
23
|
+
);
|
|
24
|
+
assert.deepEqual(p.args, ['a.ts', 'b.ts']);
|
|
25
|
+
assert.equal(p.flags.direct, true);
|
|
26
|
+
assert.equal(p.flags.depth, 2);
|
|
27
|
+
assert.deepEqual(p.flags.var, ['x=1', 'y=2']);
|
|
28
|
+
assert.deepEqual(pairs(p.flags.var as string[]), { x: '1', y: '2' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('defaults, --no-flag, --flag=value and passthrough after --', () => {
|
|
32
|
+
const p = parseArgs(['--no-direct', '--out=x.json', '--', '--jest-flag'], [], FLAGS);
|
|
33
|
+
assert.equal(p.flags.direct, false);
|
|
34
|
+
assert.equal(p.flags.depth, 4);
|
|
35
|
+
assert.equal(p.flags.out, 'x.json');
|
|
36
|
+
assert.deepEqual(p.passthrough, ['--jest-flag']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('unknown flags, missing values and missing positionals are usage errors that name what exists', () => {
|
|
40
|
+
assert.throws(
|
|
41
|
+
() => parseArgs(['--nope'], [], FLAGS),
|
|
42
|
+
(e: unknown) => e instanceof UsageError && /--direct/.test((e as Error).message),
|
|
43
|
+
);
|
|
44
|
+
assert.throws(() => parseArgs(['--depth', '--direct'], [], FLAGS), /--depth needs a value/);
|
|
45
|
+
assert.throws(() => parseArgs(['--depth', 'x'], [], FLAGS), /expects a number/);
|
|
46
|
+
assert.throws(() => parseArgs([], [{ name: 'target', help: '', required: true }], FLAGS), /missing <target>/);
|
|
47
|
+
assert.throws(() => parseArgs(['a', 'b'], [{ name: 'one', help: '' }], FLAGS), /unexpected argument "b"/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a session line splits on spaces and keeps quoted text together', () => {
|
|
51
|
+
assert.deepEqual(splitLine('press "Comenzar ya" --hold 200'), ['press', 'Comenzar ya', '--hold', '200']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('every command is named once, documented, and its aliases are unique', () => {
|
|
55
|
+
const all = [...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...headlessCommands];
|
|
56
|
+
const names = new Set<string>();
|
|
57
|
+
for (const c of all) {
|
|
58
|
+
assert.ok(c.summary.length > 10, `${c.name} has a summary`);
|
|
59
|
+
assert.ok(!names.has(c.name), `${c.name} defined once`);
|
|
60
|
+
names.add(c.name);
|
|
61
|
+
for (const a of c.aliases ?? []) {
|
|
62
|
+
assert.ok(!names.has(a), `${a} unique`);
|
|
63
|
+
names.add(a);
|
|
64
|
+
}
|
|
65
|
+
for (const [flag, spec] of Object.entries(c.flags ?? {})) assert.ok(spec.help, `${c.name} --${flag} has help`);
|
|
66
|
+
for (const p of c.positionals ?? []) assert.ok(p.help, `${c.name} <${p.name}> has help`);
|
|
67
|
+
}
|
|
68
|
+
for (const short of ['plan', 'deeplink', 'appstate', 'network', 'clock', 'biometrics', 'scan', 'gallery', 'headless'])
|
|
69
|
+
assert.ok(names.has(short), `${short} accepted`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('MCP serves a small core by default and everything else on request', () => {
|
|
73
|
+
const all = [...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...headlessCommands];
|
|
74
|
+
const core = all.filter((c) => c.mcp === 'core').map((c) => c.name);
|
|
75
|
+
assert.deepEqual(core.sort(), ['call', 'map plan', 'map show', 'os deeplink', 'press', 'screen', 'type', 'wait', 'why']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('what MCP leaves out of a tool is still a flag of the command', () => {
|
|
79
|
+
for (const c of [...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...headlessCommands]) {
|
|
80
|
+
for (const flag of c.mcpOmit ?? []) assert.ok(c.flags?.[flag], `${c.name} omits --${flag}, which it does not have`);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { diffGraphs, newFindings } from '../commands/check.js';
|
|
5
|
+
import { mapCheckWorkflow } from '../commands/map.js';
|
|
6
|
+
import { summarizeRuns } from '../commands/flake.js';
|
|
7
|
+
import type { FlowReport } from '../flow/types.js';
|
|
8
|
+
import type { ScreenGraph } from '../knowledge/types.js';
|
|
9
|
+
|
|
10
|
+
const graph = (screens: Record<string, string | null>, files: Record<string, { selectors?: string[]; navigates?: string[] }>): ScreenGraph =>
|
|
11
|
+
({
|
|
12
|
+
app: 'x',
|
|
13
|
+
root: '/x',
|
|
14
|
+
extractedAt: '',
|
|
15
|
+
warnings: [],
|
|
16
|
+
screens: Object.fromEntries(Object.entries(screens).map(([route, file]) => [route, { route, component: route, file, declaredIn: 'nav.tsx' }])),
|
|
17
|
+
files: Object.fromEntries(
|
|
18
|
+
Object.entries(files).map(([file, f]) => [
|
|
19
|
+
file,
|
|
20
|
+
{
|
|
21
|
+
selectors: (f.selectors ?? []).map((id) => ({ id, line: 1 })),
|
|
22
|
+
navigates: (f.navigates ?? []).map((to) => ({ to, line: 1, confidence: 'static', via: { kind: 'unknown' } })),
|
|
23
|
+
imports: [],
|
|
24
|
+
},
|
|
25
|
+
]),
|
|
26
|
+
),
|
|
27
|
+
}) as unknown as ScreenGraph;
|
|
28
|
+
|
|
29
|
+
test('a graph diff names routes, navigations and testIDs that changed', () => {
|
|
30
|
+
const base = graph({ A: 'a.tsx', B: 'b.tsx' }, { 'a.tsx': { selectors: ['go-b'], navigates: ['B'] }, 'b.tsx': { selectors: ['submit'] } });
|
|
31
|
+
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': {} });
|
|
32
|
+
const d = diffGraphs(base, head);
|
|
33
|
+
assert.deepEqual(d.routesAdded, ['C']);
|
|
34
|
+
assert.deepEqual(d.edgesAdded, [{ from: 'A', to: 'C' }]);
|
|
35
|
+
assert.deepEqual(d.selectorsAdded, [{ file: 'a.tsx', id: 'go-c' }]);
|
|
36
|
+
assert.deepEqual(d.selectorsRemoved, [{ file: 'b.tsx', id: 'submit' }]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('lint findings already in the baseline are not new', () => {
|
|
40
|
+
const f1 = { finding: { rule: 'unlabeled-control', severity: 'warn' as const, element: 3, message: 'button without a label' }, testId: 'x' };
|
|
41
|
+
const f2 = { finding: { rule: 'small-target', severity: 'warn' as const, element: 4, message: '20×20' }, testId: 'y' };
|
|
42
|
+
const fresh = newFindings({ A: [{ rule: 'unlabeled-control', testId: 'x', message: 'button without a label' }] }, 'A', [f1, f2]);
|
|
43
|
+
assert.deepEqual(fresh, [f2]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('flake runs are summarised by the step and error they died on', () => {
|
|
47
|
+
const ok = (ms: number): FlowReport => ({ name: 'f', ok: true, totalMs: ms, vars: {}, steps: [{ step: { press: { testId: 'a' } }, ok: true, ms: 1 }] });
|
|
48
|
+
const bad = (err: string): FlowReport => ({
|
|
49
|
+
name: 'f',
|
|
50
|
+
ok: false,
|
|
51
|
+
totalMs: 900,
|
|
52
|
+
vars: {},
|
|
53
|
+
steps: [
|
|
54
|
+
{ step: { press: { testId: 'a' } }, ok: true, ms: 1 },
|
|
55
|
+
{ step: { waitFor: { route: 'A' } }, ok: false, ms: 1, error: err },
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
const s = summarizeRuns([ok(100), bad('Timed out waiting for A (on Z)'), ok(300), bad('Timed out waiting for A (on Y)')]);
|
|
59
|
+
assert.equal(s.passed, 2);
|
|
60
|
+
assert.equal(s.failures.length, 1);
|
|
61
|
+
assert.equal(s.failures[0]!.times, 2);
|
|
62
|
+
assert.equal(s.failures[0]!.step, 2);
|
|
63
|
+
assert.match(s.failures[0]!.what, /waitFor/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('the map check workflow diffs against the pull request base and keeps one comment', () => {
|
|
67
|
+
const yml = mapCheckWorkflow(2);
|
|
68
|
+
assert.match(yml, /lynkeus map check --base origin\/\$\{\{ github\.base_ref \}\} --depth 2/);
|
|
69
|
+
assert.match(yml, /--edit-last/);
|
|
70
|
+
assert.match(yml, /pull-requests: write/);
|
|
71
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
|
|
7
|
+
import { init } from '../commands/init.js';
|
|
8
|
+
import { explainError } from '../knowledge/errors.js';
|
|
9
|
+
|
|
10
|
+
const tmpApp = () => {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-init-'));
|
|
12
|
+
fs.mkdirSync(path.join(root, 'app'));
|
|
13
|
+
fs.writeFileSync(path.join(root, 'app', 'App.tsx'), 'export default () => null');
|
|
14
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'demo' }));
|
|
15
|
+
fs.writeFileSync(
|
|
16
|
+
path.join(root, 'index.js'),
|
|
17
|
+
"import { AppRegistry } from 'react-native'\nimport App from './app/App'\n\nAppRegistry.registerComponent('demo', () => App)\n",
|
|
18
|
+
);
|
|
19
|
+
return root;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
test('init writes an entry that requires the app and refuses non-local backends', () => {
|
|
23
|
+
const root = tmpApp();
|
|
24
|
+
const notes = init(root, { entry: 'qa/headless.tsx', apiEnv: 'API_URL', auto: false, force: false });
|
|
25
|
+
const entry = fs.readFileSync(path.join(root, 'qa/headless.tsx'), 'utf8');
|
|
26
|
+
assert.match(entry, /require\('\.\.\/app\/App'\)/);
|
|
27
|
+
assert.match(entry, /process\.env\.API_URL/);
|
|
28
|
+
assert.match(entry, /installDefaultMocks/);
|
|
29
|
+
assert.ok(notes.some((n) => n.includes('wrote qa/headless.tsx')));
|
|
30
|
+
// Second run leaves it alone.
|
|
31
|
+
const again = init(root, { entry: 'qa/headless.tsx', apiEnv: 'API_URL', auto: false, force: false });
|
|
32
|
+
assert.ok(again.some((n) => n.includes('left as is')));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('init --auto adds the mount after the imports, once', () => {
|
|
36
|
+
const root = tmpApp();
|
|
37
|
+
init(root, { entry: 'qa/headless.tsx', apiEnv: 'API_URL', auto: true, force: false });
|
|
38
|
+
init(root, { entry: 'qa/headless.tsx', apiEnv: 'API_URL', auto: true, force: false });
|
|
39
|
+
const index = fs.readFileSync(path.join(root, 'index.js'), 'utf8');
|
|
40
|
+
assert.equal(index.split('lynkeus-agent/auto').length, 2);
|
|
41
|
+
assert.ok(index.indexOf('auto(') < index.indexOf('registerComponent'));
|
|
42
|
+
assert.ok(index.indexOf("import App from './app/App'") < index.indexOf('lynkeus-agent/auto'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the error catalogue names the module behind a TurboModule crash', () => {
|
|
46
|
+
const [e] = explainError(
|
|
47
|
+
"Invariant Violation: TurboModuleRegistry.getEnforcing(...): 'RNCWebView' could not be found. Verify that a module by this name is registered",
|
|
48
|
+
);
|
|
49
|
+
assert.ok(e);
|
|
50
|
+
assert.match(e.cause, /RNCWebView/);
|
|
51
|
+
assert.equal(explainError('all good').length, 0);
|
|
52
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { simulatorArgv } from '../commands/device.js';
|
|
5
|
+
import { draftFlow } from '../commands/record.js';
|
|
6
|
+
import type { TraceEvent } from 'lynkeus-protocol';
|
|
7
|
+
|
|
8
|
+
const ev = (partial: Record<string, unknown>, seq: number): TraceEvent => ({ seq, t: seq * 100, ...partial }) as unknown as TraceEvent;
|
|
9
|
+
|
|
10
|
+
test('a recording becomes presses with what each one caused', () => {
|
|
11
|
+
const flow = draftFlow([
|
|
12
|
+
ev({ kind: 'route', route: 'First.Screen', path: ['First.Screen'] }, 1),
|
|
13
|
+
ev({ kind: 'touch', x: 10, y: 20, testId: 'primary-button', route: 'First.Screen' }, 2),
|
|
14
|
+
ev({ kind: 'request', method: 'GET', url: 'http://x/api/items', status: 200, ms: 40 }, 3),
|
|
15
|
+
ev({ kind: 'route', route: 'Second.Screen', path: ['Second.Screen'] }, 4),
|
|
16
|
+
ev({ kind: 'touch', x: 10, y: 20, text: 'Second option', route: 'Second.Screen' }, 5),
|
|
17
|
+
ev({ kind: 'store', store: 'session', changed: { step: { from: 1, to: 2 } } }, 6),
|
|
18
|
+
ev({ kind: 'touch', x: 5, y: 5, route: 'Second.Screen' }, 7),
|
|
19
|
+
]);
|
|
20
|
+
assert.deepEqual(flow.steps, [
|
|
21
|
+
{ press: { testId: 'primary-button' } },
|
|
22
|
+
{ waitFor: { route: 'Second.Screen', timeoutMs: 10000 } },
|
|
23
|
+
{ note: 'requests: GET /api/items 200' },
|
|
24
|
+
{ press: { text: 'Second option' } },
|
|
25
|
+
{ note: 'stores changed: session.step' },
|
|
26
|
+
{ note: 'touch at 5,5 on Second.Screen hit nothing lynkeus could name' },
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('device subcommands map to simctl and adb', () => {
|
|
31
|
+
assert.deepEqual(simulatorArgv('ios', 'UDID', 'permissions', ['grant', 'camera'], 'com.example.myapp')[0], {
|
|
32
|
+
bin: 'xcrun',
|
|
33
|
+
args: ['simctl', 'privacy', 'UDID', 'grant', 'camera', 'com.example.myapp'],
|
|
34
|
+
});
|
|
35
|
+
assert.deepEqual(simulatorArgv('ios', 'UDID', 'appearance', ['dark'])[0]!.args, ['simctl', 'ui', 'UDID', 'appearance', 'dark']);
|
|
36
|
+
assert.deepEqual(simulatorArgv('ios', 'UDID', 'location', ['-34.6,-58.4'])[0]!.args, ['simctl', 'location', 'UDID', 'set', '-34.6,-58.4']);
|
|
37
|
+
assert.equal(simulatorArgv('ios', 'UDID', 'language', ['de-DE'], 'com.example.myapp').length, 2);
|
|
38
|
+
assert.deepEqual(simulatorArgv('android', 'emu', 'permissions', ['grant', 'camera'], 'com.example.myapp')[0]!.args, [
|
|
39
|
+
'-s',
|
|
40
|
+
'emu',
|
|
41
|
+
'shell',
|
|
42
|
+
'pm',
|
|
43
|
+
'grant',
|
|
44
|
+
'com.example.myapp',
|
|
45
|
+
'android.permission.CAMERA',
|
|
46
|
+
]);
|
|
47
|
+
assert.throws(() => simulatorArgv('ios', 'UDID', 'permissions', ['grant', 'camera']), /--app/);
|
|
48
|
+
assert.throws(() => simulatorArgv('ios', 'UDID', 'appearance', ['blue']), /light\|dark/);
|
|
49
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
|
|
8
|
+
import { comparePng, snapshot } from '../commands/snapshot.js';
|
|
9
|
+
|
|
10
|
+
const png = (w: number, h: number, paint: (x: number, y: number) => [number, number, number]): Buffer => {
|
|
11
|
+
const p = new PNG({ width: w, height: h });
|
|
12
|
+
for (let y = 0; y < h; y++)
|
|
13
|
+
for (let x = 0; x < w; x++) {
|
|
14
|
+
const i = (y * w + x) * 4;
|
|
15
|
+
const [r, g, b] = paint(x, y);
|
|
16
|
+
p.data[i] = r;
|
|
17
|
+
p.data[i + 1] = g;
|
|
18
|
+
p.data[i + 2] = b;
|
|
19
|
+
p.data[i + 3] = 255;
|
|
20
|
+
}
|
|
21
|
+
return PNG.sync.write(p);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
test('pixels that moved are counted and painted into a diff', () => {
|
|
25
|
+
const a = png(10, 10, () => [255, 255, 255]);
|
|
26
|
+
const b = png(10, 10, (x, y) => (x < 2 && y < 2 ? [0, 0, 0] : [250, 250, 250]));
|
|
27
|
+
const c = comparePng(a, b);
|
|
28
|
+
assert.equal(c.different, 4); // the 250 vs 255 shade stays under the channel threshold
|
|
29
|
+
assert.equal(c.ratio, 0.04);
|
|
30
|
+
assert.ok(c.diff);
|
|
31
|
+
const d = PNG.sync.read(c.diff!);
|
|
32
|
+
assert.deepEqual([d.data[0], d.data[1], d.data[2]], [255, 0, 0]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a first capture is the baseline; a small change matches; a big one writes a diff', () => {
|
|
36
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-snap-'));
|
|
37
|
+
const lynkeusDir = path.join(root, '.lynkeus');
|
|
38
|
+
const runsDir = path.join(lynkeusDir, 'runs');
|
|
39
|
+
const white = png(20, 20, () => [255, 255, 255]);
|
|
40
|
+
assert.equal(snapshot(lynkeusDir, 'card', white, { runsDir }).status, 'baseline');
|
|
41
|
+
const nearly = png(20, 20, (x, y) => (x === 0 && y === 0 ? [0, 0, 0] : [255, 255, 255]));
|
|
42
|
+
assert.equal(snapshot(lynkeusDir, 'card', nearly, { runsDir, tolerance: 0.01 }).status, 'match');
|
|
43
|
+
const half = png(20, 20, (x) => (x < 10 ? [0, 0, 0] : [255, 255, 255]));
|
|
44
|
+
const r = snapshot(lynkeusDir, 'card', half, { runsDir });
|
|
45
|
+
assert.equal(r.status, 'mismatch');
|
|
46
|
+
assert.ok(fs.existsSync(r.diffFile!));
|
|
47
|
+
assert.equal(snapshot(lynkeusDir, 'card', half, { runsDir, update: true }).status, 'baseline');
|
|
48
|
+
assert.equal(snapshot(lynkeusDir, 'card', half, { runsDir }).status, 'match');
|
|
49
|
+
const other = png(30, 20, () => [255, 255, 255]);
|
|
50
|
+
assert.ok(snapshot(lynkeusDir, 'card', other, { runsDir }).comparison?.sizeMismatch);
|
|
51
|
+
});
|