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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { pairs, parseArgs, splitLine, UsageError } from '../args.js';
|
|
4
|
+
import { driveCommands } from '../commands/drive.js';
|
|
5
|
+
import { headlessCommands } from '../commands/headless.js';
|
|
6
|
+
import { mapCommands } from '../commands/map.js';
|
|
7
|
+
import { osCommands } from '../commands/os.js';
|
|
8
|
+
import { runsCommands } from '../commands/runs.js';
|
|
9
|
+
const FLAGS = {
|
|
10
|
+
direct: { type: 'boolean', help: '' },
|
|
11
|
+
depth: { type: 'number', help: '', default: 4 },
|
|
12
|
+
var: { type: 'list', help: '' },
|
|
13
|
+
out: { type: 'string', help: '' },
|
|
14
|
+
};
|
|
15
|
+
test('booleans never eat the next positional; numbers are numbers; lists collect', () => {
|
|
16
|
+
const p = parseArgs(['--direct', 'a.ts', 'b.ts', '--depth', '2', '--var', 'x=1', '--var', 'y=2'], [{ name: 'file', help: '', required: true, rest: true }], FLAGS);
|
|
17
|
+
assert.deepEqual(p.args, ['a.ts', 'b.ts']);
|
|
18
|
+
assert.equal(p.flags.direct, true);
|
|
19
|
+
assert.equal(p.flags.depth, 2);
|
|
20
|
+
assert.deepEqual(p.flags.var, ['x=1', 'y=2']);
|
|
21
|
+
assert.deepEqual(pairs(p.flags.var), { x: '1', y: '2' });
|
|
22
|
+
});
|
|
23
|
+
test('defaults, --no-flag, --flag=value and passthrough after --', () => {
|
|
24
|
+
const p = parseArgs(['--no-direct', '--out=x.json', '--', '--jest-flag'], [], FLAGS);
|
|
25
|
+
assert.equal(p.flags.direct, false);
|
|
26
|
+
assert.equal(p.flags.depth, 4);
|
|
27
|
+
assert.equal(p.flags.out, 'x.json');
|
|
28
|
+
assert.deepEqual(p.passthrough, ['--jest-flag']);
|
|
29
|
+
});
|
|
30
|
+
test('unknown flags, missing values and missing positionals are usage errors that name what exists', () => {
|
|
31
|
+
assert.throws(() => parseArgs(['--nope'], [], FLAGS), (e) => e instanceof UsageError && /--direct/.test(e.message));
|
|
32
|
+
assert.throws(() => parseArgs(['--depth', '--direct'], [], FLAGS), /--depth needs a value/);
|
|
33
|
+
assert.throws(() => parseArgs(['--depth', 'x'], [], FLAGS), /expects a number/);
|
|
34
|
+
assert.throws(() => parseArgs([], [{ name: 'target', help: '', required: true }], FLAGS), /missing <target>/);
|
|
35
|
+
assert.throws(() => parseArgs(['a', 'b'], [{ name: 'one', help: '' }], FLAGS), /unexpected argument "b"/);
|
|
36
|
+
});
|
|
37
|
+
test('a session line splits on spaces and keeps quoted text together', () => {
|
|
38
|
+
assert.deepEqual(splitLine('press "Comenzar ya" --hold 200'), ['press', 'Comenzar ya', '--hold', '200']);
|
|
39
|
+
});
|
|
40
|
+
test('every command is named once, documented, and its aliases are unique', () => {
|
|
41
|
+
const all = [...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...headlessCommands];
|
|
42
|
+
const names = new Set();
|
|
43
|
+
for (const c of all) {
|
|
44
|
+
assert.ok(c.summary.length > 10, `${c.name} has a summary`);
|
|
45
|
+
assert.ok(!names.has(c.name), `${c.name} defined once`);
|
|
46
|
+
names.add(c.name);
|
|
47
|
+
for (const a of c.aliases ?? []) {
|
|
48
|
+
assert.ok(!names.has(a), `${a} unique`);
|
|
49
|
+
names.add(a);
|
|
50
|
+
}
|
|
51
|
+
for (const [flag, spec] of Object.entries(c.flags ?? {}))
|
|
52
|
+
assert.ok(spec.help, `${c.name} --${flag} has help`);
|
|
53
|
+
for (const p of c.positionals ?? [])
|
|
54
|
+
assert.ok(p.help, `${c.name} <${p.name}> has help`);
|
|
55
|
+
}
|
|
56
|
+
for (const short of ['plan', 'deeplink', 'appstate', 'network', 'clock', 'biometrics', 'scan', 'gallery', 'headless'])
|
|
57
|
+
assert.ok(names.has(short), `${short} accepted`);
|
|
58
|
+
});
|
|
59
|
+
test('MCP serves a small core by default and everything else on request', () => {
|
|
60
|
+
const all = [...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...headlessCommands];
|
|
61
|
+
const core = all.filter((c) => c.mcp === 'core').map((c) => c.name);
|
|
62
|
+
assert.deepEqual(core.sort(), ['call', 'map plan', 'map show', 'os deeplink', 'press', 'screen', 'type', 'wait', 'why']);
|
|
63
|
+
});
|
|
64
|
+
test('what MCP leaves out of a tool is still a flag of the command', () => {
|
|
65
|
+
for (const c of [...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...headlessCommands]) {
|
|
66
|
+
for (const flag of c.mcpOmit ?? [])
|
|
67
|
+
assert.ok(c.flags?.[flag], `${c.name} omits --${flag}, which it does not have`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { diffGraphs, newFindings } from '../commands/check.js';
|
|
4
|
+
import { mapCheckWorkflow } from '../commands/map.js';
|
|
5
|
+
import { summarizeRuns } from '../commands/flake.js';
|
|
6
|
+
const graph = (screens, files) => ({
|
|
7
|
+
app: 'x',
|
|
8
|
+
root: '/x',
|
|
9
|
+
extractedAt: '',
|
|
10
|
+
warnings: [],
|
|
11
|
+
screens: Object.fromEntries(Object.entries(screens).map(([route, file]) => [route, { route, component: route, file, declaredIn: 'nav.tsx' }])),
|
|
12
|
+
files: Object.fromEntries(Object.entries(files).map(([file, f]) => [
|
|
13
|
+
file,
|
|
14
|
+
{
|
|
15
|
+
selectors: (f.selectors ?? []).map((id) => ({ id, line: 1 })),
|
|
16
|
+
navigates: (f.navigates ?? []).map((to) => ({ to, line: 1, confidence: 'static', via: { kind: 'unknown' } })),
|
|
17
|
+
imports: [],
|
|
18
|
+
},
|
|
19
|
+
])),
|
|
20
|
+
});
|
|
21
|
+
test('a graph diff names routes, navigations and testIDs that changed', () => {
|
|
22
|
+
const base = graph({ A: 'a.tsx', B: 'b.tsx' }, { 'a.tsx': { selectors: ['go-b'], navigates: ['B'] }, 'b.tsx': { selectors: ['submit'] } });
|
|
23
|
+
const head = graph({ A: 'a.tsx', B: 'b.tsx', C: 'c.tsx' }, { 'a.tsx': { selectors: ['go-b', 'go-c'], navigates: ['B', 'C'] }, 'b.tsx': {}, 'c.tsx': {} });
|
|
24
|
+
const d = diffGraphs(base, head);
|
|
25
|
+
assert.deepEqual(d.routesAdded, ['C']);
|
|
26
|
+
assert.deepEqual(d.edgesAdded, [{ from: 'A', to: 'C' }]);
|
|
27
|
+
assert.deepEqual(d.selectorsAdded, [{ file: 'a.tsx', id: 'go-c' }]);
|
|
28
|
+
assert.deepEqual(d.selectorsRemoved, [{ file: 'b.tsx', id: 'submit' }]);
|
|
29
|
+
});
|
|
30
|
+
test('lint findings already in the baseline are not new', () => {
|
|
31
|
+
const f1 = { finding: { rule: 'unlabeled-control', severity: 'warn', element: 3, message: 'button without a label' }, testId: 'x' };
|
|
32
|
+
const f2 = { finding: { rule: 'small-target', severity: 'warn', element: 4, message: '20×20' }, testId: 'y' };
|
|
33
|
+
const fresh = newFindings({ A: [{ rule: 'unlabeled-control', testId: 'x', message: 'button without a label' }] }, 'A', [f1, f2]);
|
|
34
|
+
assert.deepEqual(fresh, [f2]);
|
|
35
|
+
});
|
|
36
|
+
test('flake runs are summarised by the step and error they died on', () => {
|
|
37
|
+
const ok = (ms) => ({ name: 'f', ok: true, totalMs: ms, vars: {}, steps: [{ step: { press: { testId: 'a' } }, ok: true, ms: 1 }] });
|
|
38
|
+
const bad = (err) => ({
|
|
39
|
+
name: 'f',
|
|
40
|
+
ok: false,
|
|
41
|
+
totalMs: 900,
|
|
42
|
+
vars: {},
|
|
43
|
+
steps: [
|
|
44
|
+
{ step: { press: { testId: 'a' } }, ok: true, ms: 1 },
|
|
45
|
+
{ step: { waitFor: { route: 'A' } }, ok: false, ms: 1, error: err },
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
const s = summarizeRuns([ok(100), bad('Timed out waiting for A (on Z)'), ok(300), bad('Timed out waiting for A (on Y)')]);
|
|
49
|
+
assert.equal(s.passed, 2);
|
|
50
|
+
assert.equal(s.failures.length, 1);
|
|
51
|
+
assert.equal(s.failures[0].times, 2);
|
|
52
|
+
assert.equal(s.failures[0].step, 2);
|
|
53
|
+
assert.match(s.failures[0].what, /waitFor/);
|
|
54
|
+
});
|
|
55
|
+
test('the map check workflow diffs against the pull request base and keeps one comment', () => {
|
|
56
|
+
const yml = mapCheckWorkflow(2);
|
|
57
|
+
assert.match(yml, /lynkeus map check --base origin\/\$\{\{ github\.base_ref \}\} --depth 2/);
|
|
58
|
+
assert.match(yml, /--edit-last/);
|
|
59
|
+
assert.match(yml, /pull-requests: write/);
|
|
60
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import { init } from '../commands/init.js';
|
|
7
|
+
import { explainError } from '../knowledge/errors.js';
|
|
8
|
+
const tmpApp = () => {
|
|
9
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-init-'));
|
|
10
|
+
fs.mkdirSync(path.join(root, 'app'));
|
|
11
|
+
fs.writeFileSync(path.join(root, 'app', 'App.tsx'), 'export default () => null');
|
|
12
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'demo' }));
|
|
13
|
+
fs.writeFileSync(path.join(root, 'index.js'), "import { AppRegistry } from 'react-native'\nimport App from './app/App'\n\nAppRegistry.registerComponent('demo', () => App)\n");
|
|
14
|
+
return root;
|
|
15
|
+
};
|
|
16
|
+
test('init writes an entry that requires the app and refuses non-local backends', () => {
|
|
17
|
+
const root = tmpApp();
|
|
18
|
+
const notes = init(root, { entry: 'qa/headless.tsx', apiEnv: 'API_URL', auto: false, force: false });
|
|
19
|
+
const entry = fs.readFileSync(path.join(root, 'qa/headless.tsx'), 'utf8');
|
|
20
|
+
assert.match(entry, /require\('\.\.\/app\/App'\)/);
|
|
21
|
+
assert.match(entry, /process\.env\.API_URL/);
|
|
22
|
+
assert.match(entry, /installDefaultMocks/);
|
|
23
|
+
assert.ok(notes.some((n) => n.includes('wrote qa/headless.tsx')));
|
|
24
|
+
// Second run leaves it alone.
|
|
25
|
+
const again = init(root, { entry: 'qa/headless.tsx', apiEnv: 'API_URL', auto: false, force: false });
|
|
26
|
+
assert.ok(again.some((n) => n.includes('left as is')));
|
|
27
|
+
});
|
|
28
|
+
test('init --auto adds the mount after the imports, once', () => {
|
|
29
|
+
const root = tmpApp();
|
|
30
|
+
init(root, { entry: 'qa/headless.tsx', apiEnv: 'API_URL', auto: true, force: false });
|
|
31
|
+
init(root, { entry: 'qa/headless.tsx', apiEnv: 'API_URL', auto: true, force: false });
|
|
32
|
+
const index = fs.readFileSync(path.join(root, 'index.js'), 'utf8');
|
|
33
|
+
assert.equal(index.split('lynkeus-agent/auto').length, 2);
|
|
34
|
+
assert.ok(index.indexOf('auto(') < index.indexOf('registerComponent'));
|
|
35
|
+
assert.ok(index.indexOf("import App from './app/App'") < index.indexOf('lynkeus-agent/auto'));
|
|
36
|
+
});
|
|
37
|
+
test('the error catalogue names the module behind a TurboModule crash', () => {
|
|
38
|
+
const [e] = explainError("Invariant Violation: TurboModuleRegistry.getEnforcing(...): 'RNCWebView' could not be found. Verify that a module by this name is registered");
|
|
39
|
+
assert.ok(e);
|
|
40
|
+
assert.match(e.cause, /RNCWebView/);
|
|
41
|
+
assert.equal(explainError('all good').length, 0);
|
|
42
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { simulatorArgv } from '../commands/device.js';
|
|
4
|
+
import { draftFlow } from '../commands/record.js';
|
|
5
|
+
const ev = (partial, seq) => ({ seq, t: seq * 100, ...partial });
|
|
6
|
+
test('a recording becomes presses with what each one caused', () => {
|
|
7
|
+
const flow = draftFlow([
|
|
8
|
+
ev({ kind: 'route', route: 'First.Screen', path: ['First.Screen'] }, 1),
|
|
9
|
+
ev({ kind: 'touch', x: 10, y: 20, testId: 'primary-button', route: 'First.Screen' }, 2),
|
|
10
|
+
ev({ kind: 'request', method: 'GET', url: 'http://x/api/items', status: 200, ms: 40 }, 3),
|
|
11
|
+
ev({ kind: 'route', route: 'Second.Screen', path: ['Second.Screen'] }, 4),
|
|
12
|
+
ev({ kind: 'touch', x: 10, y: 20, text: 'Second option', route: 'Second.Screen' }, 5),
|
|
13
|
+
ev({ kind: 'store', store: 'session', changed: { step: { from: 1, to: 2 } } }, 6),
|
|
14
|
+
ev({ kind: 'touch', x: 5, y: 5, route: 'Second.Screen' }, 7),
|
|
15
|
+
]);
|
|
16
|
+
assert.deepEqual(flow.steps, [
|
|
17
|
+
{ press: { testId: 'primary-button' } },
|
|
18
|
+
{ waitFor: { route: 'Second.Screen', timeoutMs: 10000 } },
|
|
19
|
+
{ note: 'requests: GET /api/items 200' },
|
|
20
|
+
{ press: { text: 'Second option' } },
|
|
21
|
+
{ note: 'stores changed: session.step' },
|
|
22
|
+
{ note: 'touch at 5,5 on Second.Screen hit nothing lynkeus could name' },
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
test('device subcommands map to simctl and adb', () => {
|
|
26
|
+
assert.deepEqual(simulatorArgv('ios', 'UDID', 'permissions', ['grant', 'camera'], 'com.example.myapp')[0], {
|
|
27
|
+
bin: 'xcrun',
|
|
28
|
+
args: ['simctl', 'privacy', 'UDID', 'grant', 'camera', 'com.example.myapp'],
|
|
29
|
+
});
|
|
30
|
+
assert.deepEqual(simulatorArgv('ios', 'UDID', 'appearance', ['dark'])[0].args, ['simctl', 'ui', 'UDID', 'appearance', 'dark']);
|
|
31
|
+
assert.deepEqual(simulatorArgv('ios', 'UDID', 'location', ['-34.6,-58.4'])[0].args, ['simctl', 'location', 'UDID', 'set', '-34.6,-58.4']);
|
|
32
|
+
assert.equal(simulatorArgv('ios', 'UDID', 'language', ['de-DE'], 'com.example.myapp').length, 2);
|
|
33
|
+
assert.deepEqual(simulatorArgv('android', 'emu', 'permissions', ['grant', 'camera'], 'com.example.myapp')[0].args, [
|
|
34
|
+
'-s',
|
|
35
|
+
'emu',
|
|
36
|
+
'shell',
|
|
37
|
+
'pm',
|
|
38
|
+
'grant',
|
|
39
|
+
'com.example.myapp',
|
|
40
|
+
'android.permission.CAMERA',
|
|
41
|
+
]);
|
|
42
|
+
assert.throws(() => simulatorArgv('ios', 'UDID', 'permissions', ['grant', 'camera']), /--app/);
|
|
43
|
+
assert.throws(() => simulatorArgv('ios', 'UDID', 'appearance', ['blue']), /light\|dark/);
|
|
44
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import { PNG } from 'pngjs';
|
|
7
|
+
import { comparePng, snapshot } from '../commands/snapshot.js';
|
|
8
|
+
const png = (w, h, paint) => {
|
|
9
|
+
const p = new PNG({ width: w, height: h });
|
|
10
|
+
for (let y = 0; y < h; y++)
|
|
11
|
+
for (let x = 0; x < w; x++) {
|
|
12
|
+
const i = (y * w + x) * 4;
|
|
13
|
+
const [r, g, b] = paint(x, y);
|
|
14
|
+
p.data[i] = r;
|
|
15
|
+
p.data[i + 1] = g;
|
|
16
|
+
p.data[i + 2] = b;
|
|
17
|
+
p.data[i + 3] = 255;
|
|
18
|
+
}
|
|
19
|
+
return PNG.sync.write(p);
|
|
20
|
+
};
|
|
21
|
+
test('pixels that moved are counted and painted into a diff', () => {
|
|
22
|
+
const a = png(10, 10, () => [255, 255, 255]);
|
|
23
|
+
const b = png(10, 10, (x, y) => (x < 2 && y < 2 ? [0, 0, 0] : [250, 250, 250]));
|
|
24
|
+
const c = comparePng(a, b);
|
|
25
|
+
assert.equal(c.different, 4); // the 250 vs 255 shade stays under the channel threshold
|
|
26
|
+
assert.equal(c.ratio, 0.04);
|
|
27
|
+
assert.ok(c.diff);
|
|
28
|
+
const d = PNG.sync.read(c.diff);
|
|
29
|
+
assert.deepEqual([d.data[0], d.data[1], d.data[2]], [255, 0, 0]);
|
|
30
|
+
});
|
|
31
|
+
test('a first capture is the baseline; a small change matches; a big one writes a diff', () => {
|
|
32
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-snap-'));
|
|
33
|
+
const lynkeusDir = path.join(root, '.lynkeus');
|
|
34
|
+
const runsDir = path.join(lynkeusDir, 'runs');
|
|
35
|
+
const white = png(20, 20, () => [255, 255, 255]);
|
|
36
|
+
assert.equal(snapshot(lynkeusDir, 'card', white, { runsDir }).status, 'baseline');
|
|
37
|
+
const nearly = png(20, 20, (x, y) => (x === 0 && y === 0 ? [0, 0, 0] : [255, 255, 255]));
|
|
38
|
+
assert.equal(snapshot(lynkeusDir, 'card', nearly, { runsDir, tolerance: 0.01 }).status, 'match');
|
|
39
|
+
const half = png(20, 20, (x) => (x < 10 ? [0, 0, 0] : [255, 255, 255]));
|
|
40
|
+
const r = snapshot(lynkeusDir, 'card', half, { runsDir });
|
|
41
|
+
assert.equal(r.status, 'mismatch');
|
|
42
|
+
assert.ok(fs.existsSync(r.diffFile));
|
|
43
|
+
assert.equal(snapshot(lynkeusDir, 'card', half, { runsDir, update: true }).status, 'baseline');
|
|
44
|
+
assert.equal(snapshot(lynkeusDir, 'card', half, { runsDir }).status, 'match');
|
|
45
|
+
const other = png(30, 20, () => [255, 255, 255]);
|
|
46
|
+
assert.ok(snapshot(lynkeusDir, 'card', other, { runsDir }).comparison?.sizeMismatch);
|
|
47
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { describeScreen } from '../device/driver.js';
|
|
4
|
+
import { parseTarget, scopedTarget } from '../targets.js';
|
|
5
|
+
test('a #-prefixed string is a testId', () => {
|
|
6
|
+
assert.deepEqual(parseTarget('#next-button'), { testId: 'next-button' });
|
|
7
|
+
});
|
|
8
|
+
test('an x,y pair is a point', () => {
|
|
9
|
+
assert.deepEqual(parseTarget('100,200'), { x: 100, y: 200 });
|
|
10
|
+
});
|
|
11
|
+
test('a bare integer is an index', () => {
|
|
12
|
+
assert.deepEqual(parseTarget('12'), { i: 12 });
|
|
13
|
+
});
|
|
14
|
+
test('anything else is text', () => {
|
|
15
|
+
assert.deepEqual(parseTarget('Some label'), { text: 'Some label' });
|
|
16
|
+
});
|
|
17
|
+
test('a testId that contains a comma is still a testId, not a point', () => {
|
|
18
|
+
assert.deepEqual(parseTarget('#a,b'), { testId: 'a,b' });
|
|
19
|
+
});
|
|
20
|
+
test('describeScreen prints the value a QR image carries', () => {
|
|
21
|
+
const screen = {
|
|
22
|
+
route: 'X',
|
|
23
|
+
path: ['X'],
|
|
24
|
+
commit: 1,
|
|
25
|
+
busy: false,
|
|
26
|
+
window: { w: 390, h: 844 },
|
|
27
|
+
costMs: 1,
|
|
28
|
+
elements: [{ i: 0, kind: 'image', frame: { x: 0, y: 0, w: 168, h: 169 }, enabled: true, depth: 0, accessibilityLabel: 'qr', value: 'QR-TEST-0001' }],
|
|
29
|
+
};
|
|
30
|
+
assert.match(describeScreen(screen), /image .*value="QR-TEST-0001"/);
|
|
31
|
+
});
|
|
32
|
+
test('describeScreen lists a view with a testID and leaves plain layout out', () => {
|
|
33
|
+
const screen = {
|
|
34
|
+
route: 'X',
|
|
35
|
+
path: ['X'],
|
|
36
|
+
commit: 1,
|
|
37
|
+
busy: false,
|
|
38
|
+
window: { w: 390, h: 844 },
|
|
39
|
+
costMs: 1,
|
|
40
|
+
elements: [
|
|
41
|
+
{ i: 0, kind: 'view', frame: { x: 0, y: 0, w: 390, h: 844 }, enabled: true, depth: 0 },
|
|
42
|
+
{ i: 1, kind: 'view', frame: { x: 0, y: 0, w: 390, h: 400 }, enabled: true, depth: 1, testId: 'intro-page-advances' },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
const text = describeScreen(screen);
|
|
46
|
+
assert.match(text, /view +#intro-page-advances/);
|
|
47
|
+
assert.doesNotMatch(text, /^0? ?view$/m);
|
|
48
|
+
});
|
|
49
|
+
test('describeScreen leaves out what is not presented, counts it, and lists it with --all', () => {
|
|
50
|
+
const el = (i, over) => ({ i, kind: 'button', frame: { x: 0, y: 0, w: 100, h: 40 }, enabled: true, depth: 0, ...over });
|
|
51
|
+
const screen = {
|
|
52
|
+
route: 'X',
|
|
53
|
+
path: ['X'],
|
|
54
|
+
commit: 7,
|
|
55
|
+
busy: false,
|
|
56
|
+
window: { w: 390, h: 844 },
|
|
57
|
+
costMs: 3,
|
|
58
|
+
elements: [
|
|
59
|
+
el(0, { testId: 'tab-a', hidden: 'a11y' }),
|
|
60
|
+
el(1, { testId: 'behind', hidden: 'behind-overlay' }),
|
|
61
|
+
el(2, { testId: 'slide', hidden: 'inert' }),
|
|
62
|
+
el(3, { testId: 'close', enabled: false }),
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
const text = describeScreen(screen);
|
|
66
|
+
assert.equal(text.split('\n')[0] ?? '', 'X · 2 hidden: 1 a11y, 1 behind-overlay');
|
|
67
|
+
assert.doesNotMatch(text, /#tab-a|#behind/);
|
|
68
|
+
assert.match(text, /^button #slide ~inert$/m);
|
|
69
|
+
assert.match(text, /^button #close !disabled$/m);
|
|
70
|
+
assert.match(describeScreen(screen, { all: true }), /#behind ~behind-overlay/);
|
|
71
|
+
assert.match(describeScreen(screen, { frames: true }), /#slide ~inert @50,20$/m);
|
|
72
|
+
assert.match(describeScreen(screen, { verbose: true }).split('\n')[0] ?? '', /commit=7 3ms/);
|
|
73
|
+
});
|
|
74
|
+
test("describeScreen leaves a button's icon out", () => {
|
|
75
|
+
const screen = {
|
|
76
|
+
route: 'X',
|
|
77
|
+
path: ['X'],
|
|
78
|
+
commit: 1,
|
|
79
|
+
busy: false,
|
|
80
|
+
window: { w: 390, h: 844 },
|
|
81
|
+
costMs: 1,
|
|
82
|
+
elements: [
|
|
83
|
+
{ i: 0, kind: 'button', frame: { x: 0, y: 0, w: 100, h: 40 }, enabled: true, depth: 0, testId: 'open' },
|
|
84
|
+
{ i: 1, kind: 'view', frame: { x: 0, y: 0, w: 20, h: 20 }, enabled: true, depth: 1, testId: 'SvgArrow', parent: 0 },
|
|
85
|
+
{ i: 2, kind: 'view', frame: { x: 0, y: 50, w: 20, h: 20 }, enabled: true, depth: 0, testId: 'page-marker' },
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
assert.doesNotMatch(describeScreen(screen), /SvgArrow/);
|
|
89
|
+
assert.match(describeScreen(screen), /#page-marker/);
|
|
90
|
+
assert.match(describeScreen(screen, { all: true }), /SvgArrow/);
|
|
91
|
+
});
|
|
92
|
+
test('describeScreen gives an index only to what cannot be named apart', () => {
|
|
93
|
+
const el = (i, over) => ({ i, kind: 'button', frame: { x: 0, y: 0, w: 100, h: 40 }, enabled: true, depth: 0, ...over });
|
|
94
|
+
const screen = {
|
|
95
|
+
route: 'X',
|
|
96
|
+
path: ['X'],
|
|
97
|
+
commit: 1,
|
|
98
|
+
busy: false,
|
|
99
|
+
window: { w: 390, h: 844 },
|
|
100
|
+
costMs: 1,
|
|
101
|
+
elements: [
|
|
102
|
+
el(0, { testId: 'save' }),
|
|
103
|
+
el(1, { testId: 'like' }),
|
|
104
|
+
el(2, { testId: 'like' }),
|
|
105
|
+
el(3, { kind: 'text', text: 'Hello' }),
|
|
106
|
+
el(4, { kind: 'view', testId: 'marker', accessibilityLabel: 'marker' }),
|
|
107
|
+
el(5, {}),
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
assert.deepEqual(describeScreen(screen).split('\n').slice(1), [
|
|
111
|
+
'button #save',
|
|
112
|
+
'1 button #like',
|
|
113
|
+
'2 button #like',
|
|
114
|
+
'text "Hello"',
|
|
115
|
+
'view #marker',
|
|
116
|
+
'5 button',
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
test('a [n] suffix picks the nth match of a testId or text', () => {
|
|
120
|
+
assert.deepEqual(parseTarget('#field[2]'), { testId: 'field', nth: 2 });
|
|
121
|
+
assert.deepEqual(parseTarget('Ciudad[0]'), { text: 'Ciudad', nth: 0 });
|
|
122
|
+
});
|
|
123
|
+
test('--in and --nth narrow a target', () => {
|
|
124
|
+
assert.deepEqual(scopedTarget('#like-message-button', { in: 'hi from Ana' }), { testId: 'like-message-button', within: { text: 'hi from Ana' } });
|
|
125
|
+
assert.deepEqual(scopedTarget('#like-message-button', { in: '#feed', nth: 2 }), { testId: 'like-message-button', within: { testId: 'feed' }, nth: 2 });
|
|
126
|
+
assert.deepEqual(scopedTarget('100,200', { in: '#feed' }), { x: 100, y: 200 });
|
|
127
|
+
});
|
package/lib/args.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booleans never consume the next token (`--direct a.ts` keeps `a.ts`),
|
|
3
|
+
* `--no-<flag>` clears one, and a repeated list flag collects.
|
|
4
|
+
*/
|
|
5
|
+
export type FlagSpec = {
|
|
6
|
+
type: 'string' | 'number' | 'boolean' | 'list';
|
|
7
|
+
help: string;
|
|
8
|
+
default?: string | number | boolean;
|
|
9
|
+
/** The placeholder in usage: `--timeout <ms>`. */
|
|
10
|
+
value?: string;
|
|
11
|
+
};
|
|
12
|
+
export type PositionalSpec = {
|
|
13
|
+
name: string;
|
|
14
|
+
help: string;
|
|
15
|
+
required?: boolean;
|
|
16
|
+
/** Takes every argument left. */
|
|
17
|
+
rest?: boolean;
|
|
18
|
+
};
|
|
19
|
+
export type FlagValues = Record<string, string | number | boolean | string[] | undefined>;
|
|
20
|
+
export declare class UsageError extends Error {
|
|
21
|
+
}
|
|
22
|
+
export declare const GLOBAL_FLAGS: Record<string, FlagSpec>;
|
|
23
|
+
export declare const parseArgs: (argv: string[], positionals?: PositionalSpec[], flags?: Record<string, FlagSpec>) => {
|
|
24
|
+
args: string[];
|
|
25
|
+
flags: FlagValues;
|
|
26
|
+
passthrough: string[];
|
|
27
|
+
};
|
|
28
|
+
export declare const pairs: (list: string[] | undefined) => Record<string, string>;
|
|
29
|
+
/** Double quotes group; everything else splits on spaces. */
|
|
30
|
+
export declare const splitLine: (line: string) => string[];
|
package/lib/args.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export class UsageError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export const GLOBAL_FLAGS = {
|
|
4
|
+
root: { type: 'string', help: "the app's folder (default: the current one)", value: 'dir' },
|
|
5
|
+
device: { type: 'string', help: 'simulator udid or name', value: 'udid|name' },
|
|
6
|
+
app: { type: 'string', help: 'bundle id, for launching and simulator commands', value: 'bundleId' },
|
|
7
|
+
launch: { type: 'boolean', help: 'relaunch the app before attaching (needs --app)' },
|
|
8
|
+
'attach-timeout': { type: 'number', help: 'how long to wait for the app to attach', default: 15000, value: 'ms' },
|
|
9
|
+
json: { type: 'boolean', help: 'machine-readable output when the command has one' },
|
|
10
|
+
help: { type: 'boolean', help: 'this text' },
|
|
11
|
+
};
|
|
12
|
+
export const parseArgs = (argv, positionals = [], flags = {}) => {
|
|
13
|
+
const specs = { ...GLOBAL_FLAGS, ...flags };
|
|
14
|
+
const out = {};
|
|
15
|
+
const args = [];
|
|
16
|
+
let passthrough = [];
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const token = argv[i];
|
|
19
|
+
if (token === '--') {
|
|
20
|
+
passthrough = argv.slice(i + 1);
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
if (token.startsWith('--') && token.length > 2) {
|
|
24
|
+
const eq = token.indexOf('=');
|
|
25
|
+
let name = eq === -1 ? token.slice(2) : token.slice(2, eq);
|
|
26
|
+
let inline = eq === -1 ? undefined : token.slice(eq + 1);
|
|
27
|
+
let negated = false;
|
|
28
|
+
if (!specs[name] && name.startsWith('no-') && specs[name.slice(3)]?.type === 'boolean') {
|
|
29
|
+
name = name.slice(3);
|
|
30
|
+
negated = true;
|
|
31
|
+
}
|
|
32
|
+
const spec = specs[name];
|
|
33
|
+
if (!spec)
|
|
34
|
+
throw new UsageError(`unknown flag --${name}; flags: ${Object.keys(specs)
|
|
35
|
+
.map((f) => `--${f}`)
|
|
36
|
+
.join(' ')}`);
|
|
37
|
+
if (spec.type === 'boolean') {
|
|
38
|
+
out[name] = !negated && (inline === undefined ? true : inline !== 'false');
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (inline === undefined) {
|
|
42
|
+
inline = argv[i + 1];
|
|
43
|
+
if (inline === undefined || (inline.startsWith('--') && inline.length > 2))
|
|
44
|
+
throw new UsageError(`--${name} needs a value${spec.value ? ` <${spec.value}>` : ''}`);
|
|
45
|
+
i++;
|
|
46
|
+
}
|
|
47
|
+
if (spec.type === 'number') {
|
|
48
|
+
const n = Number(inline);
|
|
49
|
+
if (Number.isNaN(n))
|
|
50
|
+
throw new UsageError(`--${name} expects a number, got "${inline}"`);
|
|
51
|
+
out[name] = n;
|
|
52
|
+
}
|
|
53
|
+
else if (spec.type === 'list') {
|
|
54
|
+
out[name] = [...(out[name] ?? []), inline];
|
|
55
|
+
}
|
|
56
|
+
else
|
|
57
|
+
out[name] = inline;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
args.push(token);
|
|
61
|
+
}
|
|
62
|
+
for (const [name, spec] of Object.entries(specs))
|
|
63
|
+
if (out[name] === undefined && spec.default !== undefined)
|
|
64
|
+
out[name] = spec.default;
|
|
65
|
+
const required = positionals.filter((p) => p.required);
|
|
66
|
+
if (args.length < required.length)
|
|
67
|
+
throw new UsageError(`missing ${required
|
|
68
|
+
.slice(args.length)
|
|
69
|
+
.map((p) => `<${p.name}>`)
|
|
70
|
+
.join(' ')}`);
|
|
71
|
+
const last = positionals[positionals.length - 1];
|
|
72
|
+
if (!last?.rest && args.length > positionals.length)
|
|
73
|
+
throw new UsageError(`unexpected argument "${args[positionals.length]}"`);
|
|
74
|
+
return { args, flags: out, passthrough };
|
|
75
|
+
};
|
|
76
|
+
export const pairs = (list) => {
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const item of list ?? []) {
|
|
79
|
+
const eq = item.indexOf('=');
|
|
80
|
+
if (eq > 0)
|
|
81
|
+
out[item.slice(0, eq)] = item.slice(eq + 1);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
/** Double quotes group; everything else splits on spaces. */
|
|
86
|
+
export const splitLine = (line) => (line.match(/"[^"]*"|\S+/g) ?? []).map((a) => a.replace(/^"|"$/g, ''));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { dig, interpolate, matcherOf, matches, parseCase, readCases } from '../format.js';
|
|
7
|
+
const CASE = `---
|
|
8
|
+
id: gift
|
|
9
|
+
title: "A gift shows up"
|
|
10
|
+
---
|
|
11
|
+
setup:
|
|
12
|
+
- create_user: { as: u, name: "Ana" }
|
|
13
|
+
steps:
|
|
14
|
+
- app.press: "#open"
|
|
15
|
+
- app.back
|
|
16
|
+
- assert: { user: u, path: score, equals: 25 }
|
|
17
|
+
expected: |
|
|
18
|
+
The score is 25.
|
|
19
|
+
`;
|
|
20
|
+
test('a case is its frontmatter, its setup, its steps and what it expects', () => {
|
|
21
|
+
const c = parseCase(CASE, 'cases/gift.md');
|
|
22
|
+
assert.equal(c.id, 'gift');
|
|
23
|
+
assert.deepEqual(c.setup, [{ verb: 'create_user', value: { as: 'u', name: 'Ana' } }]);
|
|
24
|
+
assert.deepEqual(c.steps.map((s) => s.verb), ['app.press', 'app.back', 'assert']);
|
|
25
|
+
assert.equal(c.expected, 'The score is 25.');
|
|
26
|
+
});
|
|
27
|
+
test('a file without frontmatter, or a step with two verbs, is refused with its place', () => {
|
|
28
|
+
assert.throws(() => parseCase('steps: []', 'x.md'), /x\.md: no frontmatter/);
|
|
29
|
+
assert.throws(() => parseCase('---\nid: x\n---\nsteps:\n - { a: 1, b: 2 }\n', 'x.md'), /steps\[0\] must be one step/);
|
|
30
|
+
});
|
|
31
|
+
test('a path reads into lists, and a star collects from every element', () => {
|
|
32
|
+
const json = { accounts: [{ code: 'usd' }, { code: 'eur' }], user: { id: 7 } };
|
|
33
|
+
assert.equal(dig(json, 'accounts.1.code'), 'eur');
|
|
34
|
+
assert.deepEqual(dig(json, 'accounts.*.code'), ['usd', 'eur']);
|
|
35
|
+
assert.equal(dig(json, 'user.name.first'), undefined);
|
|
36
|
+
});
|
|
37
|
+
test('a reference alone keeps its type; inside text it is text; a missing one says which', () => {
|
|
38
|
+
const scope = { u: { user_id: 7, name: 'Ana' } };
|
|
39
|
+
assert.deepEqual(interpolate({ id: '{{u.user_id}}', hi: 'hi {{u.name}}', n: 3 }, scope), { id: 7, hi: 'hi Ana', n: 3 });
|
|
40
|
+
assert.match(interpolate('x{{random.letters}}', scope), /^x[a-z]{6}$/);
|
|
41
|
+
assert.throws(() => interpolate('{{v.user_id}}', scope), /no alias 'v'/);
|
|
42
|
+
assert.throws(() => interpolate('{{u.phone}}', scope), /\{\{u\.phone\}\} is empty/);
|
|
43
|
+
});
|
|
44
|
+
test('matchers compare numbers as numbers and lists by any element', () => {
|
|
45
|
+
assert.equal(matches('25.0', { equals: 25 }), true);
|
|
46
|
+
assert.equal(matches('abc', { equals: 'abc' }), true);
|
|
47
|
+
assert.equal(matches(['usd', 'bridge_usd'], { includes: 'bridge' }), true);
|
|
48
|
+
assert.equal(matches(9, { gte: 10 }), false);
|
|
49
|
+
assert.equal(matches(null, { absent: true }), true);
|
|
50
|
+
assert.equal(matches('AUTORIZADO', { matches: '^AUT' }), true);
|
|
51
|
+
assert.deepEqual(matcherOf({ path: 'a', equals: 1, user: 'u' }), { equals: 1 });
|
|
52
|
+
assert.equal(matcherOf({ path: 'a' }), undefined);
|
|
53
|
+
});
|
|
54
|
+
test('a case with a matrix runs once per combination, named after it', () => {
|
|
55
|
+
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'lynkeus-matrix-')), 'm.md');
|
|
56
|
+
fs.writeFileSync(file, '---\nid: m\nmatrix: { locale: [en, fr], login: [phone] }\n---\nsteps:\n - create_user: { locale: "{{matrix.locale}}" }\n');
|
|
57
|
+
const cases = readCases(file);
|
|
58
|
+
assert.deepEqual(cases.map((c) => c.id), ['m[locale=en,login=phone]', 'm[locale=fr,login=phone]']);
|
|
59
|
+
assert.deepEqual(cases[1].setup, []);
|
|
60
|
+
assert.deepEqual(cases[1].steps[0].value, { locale: 'fr' });
|
|
61
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|