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