@itmetsam/samurai-framework 0.20.0 → 0.21.1
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/README.md +2 -2
- package/dist/api.d.ts +1 -1
- package/dist/assert/assertion-error.d.ts +3 -1
- package/dist/assert/assertion-error.js +2 -2
- package/dist/assert/locator-assertions.d.ts +35 -4
- package/dist/assert/locator-assertions.js +56 -28
- package/dist/assert/page-assertions.js +1 -1
- package/dist/assert/pending-assertions.d.ts +3 -2
- package/dist/assert/pending-assertions.js +5 -4
- package/dist/assert/text-match.d.ts +6 -0
- package/dist/assert/text-match.js +12 -0
- package/dist/browser/browser.js +19 -3
- package/dist/browser/chrome-launch.d.ts +2 -0
- package/dist/browser/chrome-launch.js +5 -0
- package/dist/capabilities.d.ts +16 -5
- package/dist/capabilities.js +16 -5
- package/dist/cli/flow-reporter.d.ts +1 -1
- package/dist/cli/flow-reporter.js +2 -2
- package/dist/cli/flow.js +4 -4
- package/dist/config/mask.d.ts +6 -2
- package/dist/config/mask.js +4 -1
- package/dist/flows/core/check-io.d.ts +23 -0
- package/dist/flows/core/check-io.js +84 -0
- package/dist/flows/core/check.d.ts +16 -2
- package/dist/flows/core/check.js +20 -4
- package/dist/flows/core/expression/scope.d.ts +9 -2
- package/dist/flows/core/expression/scope.js +12 -4
- package/dist/flows/core/index.d.ts +2 -1
- package/dist/flows/core/index.js +2 -1
- package/dist/flows/core/nodes/condition.js +0 -1
- package/dist/flows/core/nodes/define.d.ts +0 -2
- package/dist/flows/core/nodes/group.js +0 -1
- package/dist/flows/core/nodes/test.js +0 -1
- package/dist/flows/core/schema.d.ts +10 -5
- package/dist/flows/core/schema.js +29 -1
- package/dist/flows/core/steps.d.ts +7 -0
- package/dist/flows/core/steps.js +15 -0
- package/dist/flows/core/wiring.d.ts +72 -0
- package/dist/flows/core/wiring.js +151 -0
- package/dist/flows/run/branch-context.d.ts +10 -4
- package/dist/flows/run/branch-context.js +6 -4
- package/dist/flows/run/events.d.ts +30 -2
- package/dist/flows/run/handlers/group.js +6 -2
- package/dist/flows/run/handlers/support.d.ts +7 -2
- package/dist/flows/run/handlers/support.js +36 -7
- package/dist/flows/run/handlers/test.js +6 -2
- package/dist/flows/run/handlers/types.d.ts +11 -3
- package/dist/flows/run/node-folder.d.ts +8 -3
- package/dist/flows/run/node-folder.js +28 -4
- package/dist/flows/run/node-inputs.d.ts +5 -0
- package/dist/flows/run/node-inputs.js +62 -0
- package/dist/flows/run/node-pool.d.ts +2 -2
- package/dist/flows/run/node-pool.js +6 -6
- package/dist/flows/run/node-runner.d.ts +24 -0
- package/dist/flows/run/node-runner.js +48 -0
- package/dist/flows/run/per-browser.d.ts +21 -0
- package/dist/flows/run/per-browser.js +46 -0
- package/dist/flows/run/project-facts.d.ts +13 -0
- package/dist/flows/run/project-facts.js +12 -0
- package/dist/flows/run/project-options.d.ts +9 -2
- package/dist/flows/run/project.d.ts +13 -8
- package/dist/flows/run/project.js +48 -51
- package/dist/flows/run/report.d.ts +7 -7
- package/dist/flows/run/report.js +3 -5
- package/dist/flows/run/run.d.ts +21 -7
- package/dist/flows/run/run.js +21 -11
- package/dist/flows/run/walker.d.ts +23 -7
- package/dist/flows/run/walker.js +18 -7
- package/dist/locator/locator.d.ts +3 -1
- package/dist/locator/locator.js +6 -0
- package/dist/recorder/apply.js +17 -7
- package/dist/recorder/normalise.d.ts +18 -3
- package/dist/recorder/normalise.js +18 -3
- package/dist/recorder/session-secrets.js +4 -0
- package/dist/recorder/toolbar/action-guard.d.ts +2 -5
- package/dist/recorder/toolbar/action-guard.js +9 -5
- package/dist/recorder/toolbar/actions/insert.d.ts +5 -2
- package/dist/recorder/toolbar/actions/insert.js +43 -7
- package/dist/recorder/toolbar/actions/pick.d.ts +3 -0
- package/dist/recorder/toolbar/actions/pick.js +12 -0
- package/dist/recorder/toolbar/actions/reply.d.ts +2 -0
- package/dist/recorder/toolbar/actions/reply.js +9 -0
- package/dist/recorder/toolbar/actions/save-output.d.ts +15 -0
- package/dist/recorder/toolbar/actions/save-output.js +73 -0
- package/dist/recorder/toolbar/controller-fixture.d.ts +2 -2
- package/dist/recorder/toolbar/controller-fixture.js +2 -0
- package/dist/recorder/toolbar/controller.d.ts +2 -1
- package/dist/recorder/toolbar/controller.js +16 -7
- package/dist/recorder/toolbar/insertables.d.ts +7 -0
- package/dist/recorder/toolbar/insertables.js +12 -0
- package/dist/recorder/toolbar/inspect.js +3 -1
- package/dist/recorder/toolbar/protocol.d.ts +43 -3
- package/dist/recorder/toolbar/source/events-page.js +2 -1
- package/dist/recorder/toolbar/source/fields.js +11 -0
- package/dist/recorder/toolbar/source/handlers.js +19 -12
- package/dist/recorder/toolbar/source/picking.js +22 -7
- package/dist/recorder/toolbar/source/recorder-side.js +14 -0
- package/dist/recorder/toolbar/source/setup.js +3 -3
- package/dist/recorder/toolbar/source/views-panels.d.ts +1 -1
- package/dist/recorder/toolbar/source/views-panels.js +62 -10
- package/dist/recorder/toolbar/source/views-rail.js +5 -2
- package/dist/recorder/toolbar/source.d.ts +6 -5
- package/dist/recorder/toolbar/source.js +6 -5
- package/dist/recorder/toolbar/types.d.ts +12 -5
- package/dist/runner/options.d.ts +5 -0
- package/dist/runner/pooled-run.d.ts +3 -2
- package/dist/runner/pooled-run.js +4 -2
- package/dist/runner/registry.d.ts +7 -3
- package/dist/runner/registry.js +29 -11
- package/dist/runner/reporter.d.ts +4 -0
- package/dist/runner/reporter.js +2 -0
- package/dist/runner/run-test.js +23 -2
- package/dist/runner/run.d.ts +7 -0
- package/dist/runner/run.js +8 -1
- package/dist/runner/test-io.d.ts +30 -0
- package/dist/runner/test-io.js +111 -0
- package/dist/runner/test-runner.js +1 -1
- package/dist/runner/worker-protocol.d.ts +2 -0
- package/dist/runner/worker-session.js +1 -0
- package/dist/runner/worker.d.ts +1 -0
- package/dist/runner/worker.js +4 -1
- package/dist/steps/edit.d.ts +14 -3
- package/dist/steps/edit.js +65 -23
- package/dist/steps/emit.d.ts +2 -0
- package/dist/steps/emit.js +32 -8
- package/dist/steps/index.d.ts +1 -1
- package/dist/steps/model.d.ts +42 -2
- package/dist/steps/parse/expectation.d.ts +8 -1
- package/dist/steps/parse/expectation.js +17 -11
- package/dist/steps/parse/step.js +64 -8
- package/dist/steps/parse.js +25 -16
- package/dist/steps/test-options.d.ts +25 -0
- package/dist/steps/test-options.js +158 -0
- package/dist/steps/text.d.ts +7 -0
- package/dist/steps/text.js +12 -0
- package/dist/types/test.d.ts +34 -0
- package/dist/wait/output-sink.d.ts +24 -0
- package/dist/wait/output-sink.js +59 -0
- package/package.json +44 -4
|
@@ -56,6 +56,10 @@ export interface TestEndExtras extends TestRetries {
|
|
|
56
56
|
name: string;
|
|
57
57
|
path: string;
|
|
58
58
|
}[];
|
|
59
|
+
/** The test's inputs, when it declares any */
|
|
60
|
+
inputs?: Record<string, string>;
|
|
61
|
+
/** The test's saved outputs, when it declares any */
|
|
62
|
+
outputs?: Record<string, string>;
|
|
59
63
|
}
|
|
60
64
|
export default class TestReporter implements TestSink {
|
|
61
65
|
private testResults;
|
package/dist/runner/reporter.js
CHANGED
|
@@ -146,6 +146,8 @@ export default class TestReporter {
|
|
|
146
146
|
...(extras?.logs && { logs: extras.logs }),
|
|
147
147
|
...(extras?.logsDropped && { logsDropped: extras.logsDropped }),
|
|
148
148
|
...(extras?.screenshots?.length && { screenshots: extras.screenshots }),
|
|
149
|
+
...(extras?.inputs && { inputs: extras.inputs }),
|
|
150
|
+
...(extras?.outputs && { outputs: extras.outputs }),
|
|
149
151
|
...(extras?.retry && { retry: extras.retry }),
|
|
150
152
|
...(extras?.flaky && { flaky: true }),
|
|
151
153
|
...(extras?.previousAttempts?.length && {
|
package/dist/runner/run-test.js
CHANGED
|
@@ -13,6 +13,8 @@ import { isSkipSignal, staticSkip } from "./skip.js";
|
|
|
13
13
|
import { runOutput } from "./output.js";
|
|
14
14
|
import { testDirOf } from "./test-dir.js";
|
|
15
15
|
import { toTestError } from "./test-error.js";
|
|
16
|
+
import { createInputsFixture, createOutputsFixture, ioOfAttempt, missingOutputs, } from "./test-io.js";
|
|
17
|
+
import { beginOutputs, endOutputs } from "../wait/output-sink.js";
|
|
16
18
|
/**
|
|
17
19
|
* The state of one test run. A test ends once, whichever of finishing, failing, timing out, an abort or a
|
|
18
20
|
* closed browser comes first: that one is reported and the rest are ignored.
|
|
@@ -28,6 +30,8 @@ class TestOutcome {
|
|
|
28
30
|
this.closing = false;
|
|
29
31
|
/** A person closed the browser, or it crashed, while the test ran */
|
|
30
32
|
this.browserClosed = false;
|
|
33
|
+
/** The test's inputs and outputs, as its report entry lists them when it ends */
|
|
34
|
+
this.io = () => ({});
|
|
31
35
|
}
|
|
32
36
|
get isEnded() {
|
|
33
37
|
return this.ended;
|
|
@@ -49,6 +53,7 @@ class TestOutcome {
|
|
|
49
53
|
this.reporter.onTestEnd(this.test, this.browser, error, {
|
|
50
54
|
...logs,
|
|
51
55
|
screenshots: this.evidence?.screenshots,
|
|
56
|
+
...this.io(),
|
|
52
57
|
});
|
|
53
58
|
}
|
|
54
59
|
}
|
|
@@ -137,6 +142,8 @@ export async function runOneTest(test, browserName, run, options, srcDir, report
|
|
|
137
142
|
workerIndex: resources.workerIndex ?? 0,
|
|
138
143
|
env: createEnvFixture(settings.environment, settings.variables),
|
|
139
144
|
secrets: createSecretsFixture(settings.environment, secrets),
|
|
145
|
+
inputs: createInputsFixture(test.inputs, options.inputs),
|
|
146
|
+
outputs: createOutputsFixture(sink),
|
|
140
147
|
};
|
|
141
148
|
await runWithEachHooks(test.suite, fixtures, timeoutController.signal, () => test.function(fixtures));
|
|
142
149
|
// Already reported as timed out
|
|
@@ -146,12 +153,18 @@ export async function runOneTest(test, browserName, run, options, srcDir, report
|
|
|
146
153
|
if (unawaited.length > 0) {
|
|
147
154
|
outcome.finish({
|
|
148
155
|
message: unawaited
|
|
149
|
-
.map((
|
|
156
|
+
.map((label) => `${label} was not awaited`)
|
|
150
157
|
.join("\n"),
|
|
151
158
|
type: "assertion",
|
|
152
159
|
}, await judgeLogs(page, true));
|
|
153
160
|
return;
|
|
154
161
|
}
|
|
162
|
+
// Checked only after a pass: a test that failed first reports its own failure
|
|
163
|
+
const missing = missingOutputs(test.outputs, sink);
|
|
164
|
+
if (missing) {
|
|
165
|
+
outcome.finish(missing, await judgeLogs(page, true));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
155
168
|
const verdict = await judgeLogs(page, false);
|
|
156
169
|
outcome.finish(verdict.error, verdict);
|
|
157
170
|
}
|
|
@@ -168,7 +181,15 @@ export async function runOneTest(test, browserName, run, options, srcDir, report
|
|
|
168
181
|
: toTestError(err), await judgeLogs(page, true));
|
|
169
182
|
}
|
|
170
183
|
};
|
|
171
|
-
|
|
184
|
+
// Open for this attempt only: whatever a capture still saves after the attempt ended is ignored
|
|
185
|
+
const sink = beginOutputs(test.outputs ?? []);
|
|
186
|
+
outcome.io = () => ioOfAttempt(test, options.inputs, sink);
|
|
187
|
+
try {
|
|
188
|
+
await Promise.race([limit, gone, runBody()]);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
endOutputs();
|
|
192
|
+
}
|
|
172
193
|
clearTimeout(timeoutTimer);
|
|
173
194
|
if (onRunAborted)
|
|
174
195
|
options.signal?.removeEventListener("abort", onRunAborted);
|
package/dist/runner/run.d.ts
CHANGED
|
@@ -30,6 +30,13 @@ export interface FoundTest {
|
|
|
30
30
|
modifier?: Modifier;
|
|
31
31
|
/** The `{ reason }` given with the `skip` or `fixme` that applies */
|
|
32
32
|
reason?: string;
|
|
33
|
+
/** The inputs the test declares, in declaration order, with their defaults; absent when it declares none */
|
|
34
|
+
inputs?: {
|
|
35
|
+
name: string;
|
|
36
|
+
default: string;
|
|
37
|
+
}[];
|
|
38
|
+
/** The names of the outputs the test declares; absent when it declares none */
|
|
39
|
+
outputs?: string[];
|
|
33
40
|
}
|
|
34
41
|
/**
|
|
35
42
|
* Imports the project's specs without running anything and lists the tests the options select, each with the id
|
package/dist/runner/run.js
CHANGED
|
@@ -43,7 +43,7 @@ export async function listTests(options = {}) {
|
|
|
43
43
|
return inProject(options, async (runner) => {
|
|
44
44
|
const { tests, srcDir } = await runner.list();
|
|
45
45
|
return tests.map((test) => {
|
|
46
|
-
const { name, file, modifier, reason } = test;
|
|
46
|
+
const { name, file, modifier, reason, inputs, outputs } = test;
|
|
47
47
|
const relativeFile = toPosix(path.relative(srcDir, file));
|
|
48
48
|
return {
|
|
49
49
|
id: testId(relativeFile, name),
|
|
@@ -52,6 +52,13 @@ export async function listTests(options = {}) {
|
|
|
52
52
|
relativeFile,
|
|
53
53
|
...(modifier && { modifier }),
|
|
54
54
|
...(reason !== undefined && { reason }),
|
|
55
|
+
...(inputs && {
|
|
56
|
+
inputs: Object.entries(inputs).map(([name, value]) => ({
|
|
57
|
+
name,
|
|
58
|
+
default: value,
|
|
59
|
+
})),
|
|
60
|
+
}),
|
|
61
|
+
...(outputs && { outputs }),
|
|
55
62
|
};
|
|
56
63
|
});
|
|
57
64
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { TestError, TestOptions, TestOutputs } from "../types/test.js";
|
|
2
|
+
import type { OutputSink } from "../wait/output-sink.js";
|
|
3
|
+
/**
|
|
4
|
+
* Checks the `inputs` and `outputs` of a `test()` options object and returns them, leaving out an empty or absent
|
|
5
|
+
* list. `call` is the form used (`test`, `test.only`, ...), named in the error.
|
|
6
|
+
*/
|
|
7
|
+
export declare function validateTestIO(call: string, options: TestOptions): {
|
|
8
|
+
inputs?: Record<string, string>;
|
|
9
|
+
outputs?: string[];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The `inputs` fixture: each declared input with the value the run supplies, else its default. Reading a
|
|
13
|
+
* name the test did not declare throws.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createInputsFixture(declared: Record<string, string> | undefined, supplied: Record<string, string> | undefined): Readonly<Record<string, string>>;
|
|
16
|
+
/**
|
|
17
|
+
* The `outputs` fixture of one attempt: `save` waits for the element's text (non-empty, or matching the pattern), as
|
|
18
|
+
* `toHaveText` reads it, and keeps it in `sink`. It keeps to its own attempt's sink, so a late value never lands in a later test.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createOutputsFixture(sink: OutputSink): TestOutputs;
|
|
21
|
+
/** The failure of a passing attempt that did not save every output it declared */
|
|
22
|
+
export declare function missingOutputs(declared: readonly string[] | undefined, sink: OutputSink): TestError | undefined;
|
|
23
|
+
/** What an attempt's result lists: its inputs when it declares any, its outputs so far when it declares any */
|
|
24
|
+
export declare function ioOfAttempt(test: {
|
|
25
|
+
inputs?: Record<string, string>;
|
|
26
|
+
outputs?: string[];
|
|
27
|
+
}, supplied: Record<string, string> | undefined, sink: OutputSink): {
|
|
28
|
+
inputs?: Record<string, string>;
|
|
29
|
+
outputs?: Record<string, string>;
|
|
30
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { captured, textCheck } from "../assert/text-match.js";
|
|
2
|
+
import { readEventually } from "../assert/locator-assertions.js";
|
|
3
|
+
import { track } from "../assert/pending-assertions.js";
|
|
4
|
+
import { isPassthroughKey } from "../config/variables.js";
|
|
5
|
+
import { isIoName } from "../flows/core/steps.js";
|
|
6
|
+
function checkName(call, kind, name) {
|
|
7
|
+
if (typeof name !== "string" || !isIoName(name))
|
|
8
|
+
throw new Error(`${call}(): ${kind} ${quoted(name)} is not a valid name`);
|
|
9
|
+
}
|
|
10
|
+
const has = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
11
|
+
/** A name in a message: strings quoted, anything else as written */
|
|
12
|
+
function quoted(name) {
|
|
13
|
+
return typeof name === "string" ? JSON.stringify(name) : String(name);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Checks the `inputs` and `outputs` of a `test()` options object and returns them, leaving out an empty or absent
|
|
17
|
+
* list. `call` is the form used (`test`, `test.only`, ...), named in the error.
|
|
18
|
+
*/
|
|
19
|
+
export function validateTestIO(call, options) {
|
|
20
|
+
const { inputs, outputs } = options;
|
|
21
|
+
const result = {};
|
|
22
|
+
if (inputs !== undefined) {
|
|
23
|
+
if (typeof inputs !== "object" || inputs === null || Array.isArray(inputs))
|
|
24
|
+
throw new Error(`${call}(): inputs must be an object of names and defaults`);
|
|
25
|
+
for (const [name, value] of Object.entries(inputs)) {
|
|
26
|
+
checkName(call, "input", name);
|
|
27
|
+
if (typeof value !== "string")
|
|
28
|
+
throw new Error(`${call}(): input "${name}" needs a string default`);
|
|
29
|
+
}
|
|
30
|
+
if (Object.keys(inputs).length > 0)
|
|
31
|
+
result.inputs = { ...inputs };
|
|
32
|
+
}
|
|
33
|
+
if (outputs !== undefined) {
|
|
34
|
+
if (!Array.isArray(outputs))
|
|
35
|
+
throw new Error(`${call}(): outputs must be an array of names`);
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const name of outputs) {
|
|
38
|
+
checkName(call, "output", name);
|
|
39
|
+
if (seen.has(name))
|
|
40
|
+
throw new Error(`${call}(): output "${name}" is listed twice`);
|
|
41
|
+
seen.add(name);
|
|
42
|
+
}
|
|
43
|
+
if (outputs.length > 0)
|
|
44
|
+
result.outputs = [...outputs];
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The `inputs` fixture: each declared input with the value the run supplies, else its default. Reading a
|
|
50
|
+
* name the test did not declare throws.
|
|
51
|
+
*/
|
|
52
|
+
export function createInputsFixture(declared, supplied) {
|
|
53
|
+
return new Proxy(Object.freeze(resolveInputs(declared, supplied)), {
|
|
54
|
+
get(target, key, receiver) {
|
|
55
|
+
if (isPassthroughKey(target, key) || has(target, key))
|
|
56
|
+
return Reflect.get(target, key, receiver);
|
|
57
|
+
throw new Error(`inputs.${String(key)}: not declared, add it to the test's inputs`);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Each declared input with the supplied value, else its default; names the test does not declare are left out */
|
|
62
|
+
function resolveInputs(declared, supplied) {
|
|
63
|
+
return Object.fromEntries(Object.entries(declared ?? {}).map(([name, value]) => [
|
|
64
|
+
name,
|
|
65
|
+
supplied && has(supplied, name) ? supplied[name] : value,
|
|
66
|
+
]));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The `outputs` fixture of one attempt: `save` waits for the element's text (non-empty, or matching the pattern), as
|
|
70
|
+
* `toHaveText` reads it, and keeps it in `sink`. It keeps to its own attempt's sink, so a late value never lands in a later test.
|
|
71
|
+
*/
|
|
72
|
+
export function createOutputsFixture(sink) {
|
|
73
|
+
return {
|
|
74
|
+
save: (name, locator, pattern) => track("outputs.save()", saveText(sink, name, locator, pattern)),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function saveText(sink, name, locator, pattern) {
|
|
78
|
+
if (!sink.declares(name))
|
|
79
|
+
throw new Error(`outputs.save(): output ${JSON.stringify(name)} is not declared`);
|
|
80
|
+
const matches = pattern ? textCheck(pattern, "exact") : undefined;
|
|
81
|
+
const text = await readEventually({
|
|
82
|
+
name: "toHaveText",
|
|
83
|
+
locator,
|
|
84
|
+
expected: pattern ?? "an element with text",
|
|
85
|
+
read: () => locator.textContent(),
|
|
86
|
+
// Without a pattern, an element that has not rendered its value yet (no text) is waited for
|
|
87
|
+
check: (actual) => actual !== null && (matches ? matches(actual) : actual !== ""),
|
|
88
|
+
call: `outputs.save(${JSON.stringify(name)})`,
|
|
89
|
+
});
|
|
90
|
+
sink.set(name, pattern ? captured(pattern, text) : text);
|
|
91
|
+
}
|
|
92
|
+
/** The failure of a passing attempt that did not save every output it declared */
|
|
93
|
+
export function missingOutputs(declared, sink) {
|
|
94
|
+
const saved = sink.values();
|
|
95
|
+
const missing = (declared ?? []).filter((name) => !has(saved, name));
|
|
96
|
+
if (missing.length === 0)
|
|
97
|
+
return undefined;
|
|
98
|
+
return {
|
|
99
|
+
message: missing
|
|
100
|
+
.map((name) => `output ${JSON.stringify(name)} was never saved`)
|
|
101
|
+
.join("\n"),
|
|
102
|
+
type: "assertion",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** What an attempt's result lists: its inputs when it declares any, its outputs so far when it declares any */
|
|
106
|
+
export function ioOfAttempt(test, supplied, sink) {
|
|
107
|
+
return {
|
|
108
|
+
...(test.inputs && { inputs: resolveInputs(test.inputs, supplied) }),
|
|
109
|
+
...(test.outputs && { outputs: sink.values() }),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -88,7 +88,7 @@ export default class TestRunner {
|
|
|
88
88
|
env: process.env.SAMURAI_RETRIES,
|
|
89
89
|
config: config.retries,
|
|
90
90
|
});
|
|
91
|
-
const plan = planItems(selected, browsers, serialKeyOf(loaded.groups, config.groups, loaded.srcDir));
|
|
91
|
+
const plan = planItems(selected, browsers, serialKeyOf(loaded.groups, config.groups, loaded.srcDir), this.options.inputs);
|
|
92
92
|
// A pool larger than what can overlap would only hold idle workers: a group that runs one at a time needs one
|
|
93
93
|
const lanes = concurrency(plan.items);
|
|
94
94
|
const mode = poolMode({
|
|
@@ -13,6 +13,8 @@ export interface WorkItem {
|
|
|
13
13
|
serialKey?: string;
|
|
14
14
|
/** Where this test's screenshots go, instead of the run's `outputDir` (a flow gives each node its own) */
|
|
15
15
|
outputDir?: string;
|
|
16
|
+
/** The values the run supplies for the inputs tests declare (`runTests({ inputs })`) */
|
|
17
|
+
inputs?: Record<string, string>;
|
|
16
18
|
}
|
|
17
19
|
/** What a worker needs to start: the project and the settings that are the same for every test */
|
|
18
20
|
export interface WorkerInit {
|
package/dist/runner/worker.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface WorkerSession {
|
|
|
8
8
|
/** Runs one test; `onEvent` gets the reporter's events for it. Aborting `signal` ends the test as aborted */
|
|
9
9
|
runTest(test: RegisteredTestCase, browser: SupportedBrowser, signal: AbortSignal, onEvent: (event: RunEvent) => void, options?: {
|
|
10
10
|
outputDir?: string;
|
|
11
|
+
inputs?: Record<string, string>;
|
|
11
12
|
}): Promise<void>;
|
|
12
13
|
/** Ends the project's session */
|
|
13
14
|
close(): Promise<void>;
|
package/dist/runner/worker.js
CHANGED
|
@@ -54,7 +54,10 @@ export function handleMessages(channel, { open, exit }) {
|
|
|
54
54
|
id,
|
|
55
55
|
result: { ...event.result, worker: workerIndex },
|
|
56
56
|
});
|
|
57
|
-
},
|
|
57
|
+
}, {
|
|
58
|
+
...(item.outputDir !== undefined && { outputDir: item.outputDir }),
|
|
59
|
+
...(item.inputs !== undefined && { inputs: item.inputs }),
|
|
60
|
+
});
|
|
58
61
|
}
|
|
59
62
|
finally {
|
|
60
63
|
current = undefined;
|
package/dist/steps/edit.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Step } from "./model.js";
|
|
1
|
+
import type { Step, TestInput } from "./model.js";
|
|
2
2
|
export type StepEdit = {
|
|
3
3
|
op: "replace";
|
|
4
4
|
test: number;
|
|
@@ -32,10 +32,21 @@ export type StepEdit = {
|
|
|
32
32
|
| {
|
|
33
33
|
op: "remove-test";
|
|
34
34
|
test: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Sets the test's `inputs` and `outputs` in its options object: a given list replaces that list, one left out
|
|
38
|
+
* stays. When both end up empty the options object goes, unless it holds a `reason`
|
|
39
|
+
*/
|
|
40
|
+
| {
|
|
41
|
+
op: "declare";
|
|
42
|
+
test: number;
|
|
43
|
+
inputs?: TestInput[];
|
|
44
|
+
outputs?: string[];
|
|
35
45
|
};
|
|
36
46
|
/**
|
|
37
47
|
* Applies one edit and returns the new source. Only the touched statement changes, plus the test's parameters
|
|
38
|
-
* when the step reads a fixture (`env`, `secrets`) they don't have yet
|
|
39
|
-
* comments included, stays byte for
|
|
48
|
+
* when the step reads a fixture (`env`, `secrets`, `inputs`, `outputs`) they don't have yet, and its `outputs`
|
|
49
|
+
* when the step saves one not declared yet: everything else, formatting and comments included, stays byte for
|
|
50
|
+
* byte. A step that reads an undeclared input is refused: `declare` it, with its default, first.
|
|
40
51
|
*/
|
|
41
52
|
export declare function applyEdit(source: string, edit: StepEdit): string;
|
package/dist/steps/edit.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
2
|
import { stepToSource } from "./emit.js";
|
|
3
3
|
import { parseSpec } from "./parse.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
return newline === -1 ? source.length : newline + 1;
|
|
8
|
-
}
|
|
9
|
-
const indentOf = (source, offset) => /^[ \t]*/.exec(source.slice(lineStart(source, offset)))[0];
|
|
4
|
+
import { OUTPUT_MATCHERS } from "./parse/expectation.js";
|
|
5
|
+
import { declareList } from "./test-options.js";
|
|
6
|
+
import { indentOf, lineEnd, lineStart, splice } from "./text.js";
|
|
10
7
|
function pickTest(source, test) {
|
|
11
8
|
const found = parseSpec(source)[test];
|
|
12
9
|
if (!found)
|
|
@@ -19,9 +16,6 @@ function pickStep(parsed, index) {
|
|
|
19
16
|
throw new RangeError(`Test "${parsed.name}" has no step ${index}`);
|
|
20
17
|
return step;
|
|
21
18
|
}
|
|
22
|
-
function splice(source, from, to, text) {
|
|
23
|
-
return source.slice(0, from) + text + source.slice(to);
|
|
24
|
-
}
|
|
25
19
|
/** Inserts statement text so it becomes step `index`, copying the indentation of the steps around it */
|
|
26
20
|
function insertCode(source, parsed, index, code) {
|
|
27
21
|
if (index < 0 || index > parsed.steps.length) {
|
|
@@ -52,11 +46,58 @@ function removeStep(source, parsed, index) {
|
|
|
52
46
|
const { anchor, end } = pickStep(parsed, index);
|
|
53
47
|
return splice(source, lineStart(source, anchor), lineEnd(source, end), "");
|
|
54
48
|
}
|
|
49
|
+
const VALUE_FIXTURES = { env: "env", secret: "secrets", input: "inputs" };
|
|
55
50
|
/** The fixtures a step's statement reads */
|
|
56
51
|
function fixturesOf(step) {
|
|
52
|
+
if (step.kind === "saveOutput")
|
|
53
|
+
return ["outputs"];
|
|
57
54
|
if (step.kind !== "fill" || step.value.kind === "literal")
|
|
58
55
|
return [];
|
|
59
|
-
return [step.value.kind
|
|
56
|
+
return [VALUE_FIXTURES[step.value.kind]];
|
|
57
|
+
}
|
|
58
|
+
/** The output a step saves, if any */
|
|
59
|
+
function outputOf(step) {
|
|
60
|
+
if (step.kind === "saveOutput")
|
|
61
|
+
return step.name;
|
|
62
|
+
return step.kind === "fill" || step.kind === "expect"
|
|
63
|
+
? step.output
|
|
64
|
+
: undefined;
|
|
65
|
+
}
|
|
66
|
+
/** Refuses a step that reads an input the test doesn't declare: the default has to be set first */
|
|
67
|
+
function checkInputs(parsed, step) {
|
|
68
|
+
if (step.kind !== "fill" || step.value.kind !== "input")
|
|
69
|
+
return;
|
|
70
|
+
const { name } = step.value;
|
|
71
|
+
if (!parsed.inputs?.some((input) => input.name === name))
|
|
72
|
+
throw new Error(`applyEdit(): input "${name}" is not declared`);
|
|
73
|
+
}
|
|
74
|
+
/** Refuses an output the runtime can't save: from a negated check, or from a matcher that doesn't take one */
|
|
75
|
+
function checkOutput(step) {
|
|
76
|
+
if (step.kind !== "expect" || step.output === undefined)
|
|
77
|
+
return;
|
|
78
|
+
if (step.not)
|
|
79
|
+
throw new Error("applyEdit(): output can't be saved from a negated check");
|
|
80
|
+
if (!OUTPUT_MATCHERS.includes(step.expectation.matcher))
|
|
81
|
+
throw new Error(`applyEdit(): output can't be saved from ${step.expectation.matcher}`);
|
|
82
|
+
}
|
|
83
|
+
/** Adds the output a step saves to the test's `outputs`, when it isn't there yet */
|
|
84
|
+
function withOutput(source, parsed, step) {
|
|
85
|
+
const name = outputOf(step);
|
|
86
|
+
const outputs = parsed.outputs ?? [];
|
|
87
|
+
if (name === undefined || outputs.includes(name))
|
|
88
|
+
return source;
|
|
89
|
+
return declareList(source, parsed, "outputs", [...outputs, name]);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Puts a step's statement in at `[from, to)` of the body, then what it needs: its fixtures, its output and
|
|
93
|
+
* the `generatedFile` import. Each goes before the last in the file, so the parsed offsets stay valid
|
|
94
|
+
*/
|
|
95
|
+
function withStep(source, parsed, step, place) {
|
|
96
|
+
checkInputs(parsed, step);
|
|
97
|
+
checkOutput(step);
|
|
98
|
+
const next = withOutput(withFixtures(place(source, stepToSource(step)), parsed, step), parsed, step);
|
|
99
|
+
// Last: the import shifts every offset
|
|
100
|
+
return usesGeneratedFile(step) ? withGeneratedFileImport(next) : next;
|
|
60
101
|
}
|
|
61
102
|
/** Adds the fixtures the step reads to the test's parameters, when they aren't there yet */
|
|
62
103
|
function withFixtures(source, parsed, step) {
|
|
@@ -102,26 +143,27 @@ function withGeneratedFileImport(source) {
|
|
|
102
143
|
}
|
|
103
144
|
/**
|
|
104
145
|
* Applies one edit and returns the new source. Only the touched statement changes, plus the test's parameters
|
|
105
|
-
* when the step reads a fixture (`env`, `secrets`) they don't have yet
|
|
106
|
-
* comments included, stays byte for
|
|
146
|
+
* when the step reads a fixture (`env`, `secrets`, `inputs`, `outputs`) they don't have yet, and its `outputs`
|
|
147
|
+
* when the step saves one not declared yet: everything else, formatting and comments included, stays byte for
|
|
148
|
+
* byte. A step that reads an undeclared input is refused: `declare` it, with its default, first.
|
|
107
149
|
*/
|
|
108
150
|
export function applyEdit(source, edit) {
|
|
109
151
|
const parsed = pickTest(source, edit.test);
|
|
110
152
|
switch (edit.op) {
|
|
111
153
|
case "replace": {
|
|
112
154
|
const { start, end } = pickStep(parsed, edit.index);
|
|
113
|
-
|
|
114
|
-
const next = withFixtures(splice(source, start, end, stepToSource(edit.step)), parsed, edit.step);
|
|
115
|
-
// Last: the import shifts every offset
|
|
116
|
-
return usesGeneratedFile(edit.step)
|
|
117
|
-
? withGeneratedFileImport(next)
|
|
118
|
-
: next;
|
|
155
|
+
return withStep(source, parsed, edit.step, (text, code) => splice(text, start, end, code));
|
|
119
156
|
}
|
|
120
|
-
case "insert":
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
157
|
+
case "insert":
|
|
158
|
+
return withStep(source, parsed, edit.step, (text, code) => insertCode(text, parsed, edit.index, code));
|
|
159
|
+
case "declare": {
|
|
160
|
+
let next = source;
|
|
161
|
+
for (const key of ["inputs", "outputs"]) {
|
|
162
|
+
const list = edit[key];
|
|
163
|
+
if (list)
|
|
164
|
+
next = declareList(next, pickTest(next, edit.test), key, list);
|
|
165
|
+
}
|
|
166
|
+
return next;
|
|
125
167
|
}
|
|
126
168
|
case "remove":
|
|
127
169
|
return removeStep(source, parsed, edit.index);
|
package/dist/steps/emit.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { FileEntry, LocatorCall, LocatorSpec, Step, TextValue } from "./model.js";
|
|
2
2
|
/** The argument of `setInputFiles` / `dropFiles`: one entry bare, else an array */
|
|
3
3
|
export declare function filesToSource(files: readonly FileEntry[]): string;
|
|
4
|
+
/** A name that can follow a `.` or stand as an object key unquoted */
|
|
5
|
+
export declare const IDENTIFIER: RegExp;
|
|
4
6
|
export declare function callToSource(call: LocatorCall): string;
|
|
5
7
|
export declare function locatorToSource({ chain, fallbacks }: LocatorSpec): string;
|
|
6
8
|
export declare function valueToSource(value: TextValue): string;
|
package/dist/steps/emit.js
CHANGED
|
@@ -15,7 +15,8 @@ export function filesToSource(files) {
|
|
|
15
15
|
? fileEntryToSource(files[0])
|
|
16
16
|
: `[${files.map(fileEntryToSource).join(", ")}]`;
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
/** A name that can follow a `.` or stand as an object key unquoted */
|
|
19
|
+
export const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
19
20
|
function textOptions(call) {
|
|
20
21
|
const parts = [
|
|
21
22
|
call.match !== undefined && `match: ${str(call.match)}`,
|
|
@@ -48,17 +49,38 @@ export function locatorToSource({ chain, fallbacks }) {
|
|
|
48
49
|
export function valueToSource(value) {
|
|
49
50
|
if (value.kind === "literal")
|
|
50
51
|
return str(value.value);
|
|
51
|
-
const object = value.kind === "env"
|
|
52
|
+
const object = value.kind === "env"
|
|
53
|
+
? "env"
|
|
54
|
+
: value.kind === "secret"
|
|
55
|
+
? "secrets"
|
|
56
|
+
: "inputs";
|
|
52
57
|
return IDENTIFIER.test(value.name)
|
|
53
58
|
? `${object}.${value.name}`
|
|
54
59
|
: `${object}[${str(value.name)}]`;
|
|
55
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* A regex literal for a pattern. The source goes through `RegExp`, which escapes a `/` and a line break and leaves
|
|
63
|
+
* text that is already escaped alone, so a pattern typed by hand still makes a literal that compiles.
|
|
64
|
+
*/
|
|
65
|
+
function regexToSource({ source, flags }) {
|
|
66
|
+
let escaped = source;
|
|
67
|
+
try {
|
|
68
|
+
escaped = new RegExp(source, flags).source;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Not a valid pattern: written unchanged
|
|
72
|
+
}
|
|
73
|
+
return `/${escaped}/${flags}`;
|
|
74
|
+
}
|
|
56
75
|
function matcherToSource(expected) {
|
|
57
76
|
return typeof expected === "string"
|
|
58
77
|
? str(expected)
|
|
59
|
-
:
|
|
78
|
+
: regexToSource(expected.regex);
|
|
60
79
|
}
|
|
61
|
-
|
|
80
|
+
/** The `{ output }` option a fill or a check ends with, as its last argument */
|
|
81
|
+
const outputOption = (output) => output === undefined ? "" : `, { output: ${str(output)} }`;
|
|
82
|
+
function expectationToSource(expectation, output) {
|
|
83
|
+
const tail = outputOption(output);
|
|
62
84
|
switch (expectation.matcher) {
|
|
63
85
|
case "toBeVisible":
|
|
64
86
|
case "toBeHidden":
|
|
@@ -66,13 +88,13 @@ function expectationToSource(expectation) {
|
|
|
66
88
|
case "toBeChecked":
|
|
67
89
|
return `${expectation.matcher}()`;
|
|
68
90
|
case "toHaveAttribute":
|
|
69
|
-
return `toHaveAttribute(${str(expectation.name)}, ${matcherToSource(expectation.expected)})`;
|
|
91
|
+
return `toHaveAttribute(${str(expectation.name)}, ${matcherToSource(expectation.expected)}${tail})`;
|
|
70
92
|
case "toHaveCSS":
|
|
71
93
|
return `toHaveCSS(${str(expectation.name)}, ${matcherToSource(expectation.expected)})`;
|
|
72
94
|
case "toHaveCount":
|
|
73
95
|
return `toHaveCount(${expectation.expected})`;
|
|
74
96
|
default:
|
|
75
|
-
return `${expectation.matcher}(${matcherToSource(expectation.expected)})`;
|
|
97
|
+
return `${expectation.matcher}(${matcherToSource(expectation.expected)}${tail})`;
|
|
76
98
|
}
|
|
77
99
|
}
|
|
78
100
|
/** The statement for a step, on one line */
|
|
@@ -83,11 +105,11 @@ export function stepToSource(step) {
|
|
|
83
105
|
case "click":
|
|
84
106
|
return `await ${locatorToSource(step.locator)}.click();`;
|
|
85
107
|
case "fill":
|
|
86
|
-
return `await ${locatorToSource(step.locator)}.fill(${valueToSource(step.value)});`;
|
|
108
|
+
return `await ${locatorToSource(step.locator)}.fill(${valueToSource(step.value)}${outputOption(step.output)});`;
|
|
87
109
|
case "press":
|
|
88
110
|
return `await ${locatorToSource(step.locator)}.press(${str(step.key)});`;
|
|
89
111
|
case "expect":
|
|
90
|
-
return `await expect(${locatorToSource(step.locator)})${step.not ? ".not" : ""}.${expectationToSource(step.expectation)};`;
|
|
112
|
+
return `await expect(${locatorToSource(step.locator)})${step.not ? ".not" : ""}.${expectationToSource(step.expectation, step.output)};`;
|
|
91
113
|
case "expectPage":
|
|
92
114
|
return `await expect(page)${step.not ? ".not" : ""}.${expectationToSource(step.expectation)};`;
|
|
93
115
|
case "waitForNetworkIdle":
|
|
@@ -99,6 +121,8 @@ export function stepToSource(step) {
|
|
|
99
121
|
case "setInputFiles":
|
|
100
122
|
case "dropFiles":
|
|
101
123
|
return `await ${locatorToSource(step.locator)}.${step.kind}(${filesToSource(step.files)});`;
|
|
124
|
+
case "saveOutput":
|
|
125
|
+
return `await outputs.save(${str(step.name)}, ${locatorToSource(step.locator)}${step.pattern ? `, ${regexToSource(step.pattern)}` : ""});`;
|
|
102
126
|
case "custom":
|
|
103
127
|
return step.code;
|
|
104
128
|
}
|
package/dist/steps/index.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ export { applyEdit, type StepEdit } from "./edit.js";
|
|
|
3
3
|
export { stepToSource, locatorToSource, callToSource, filesToSource, valueToSource, } from "./emit.js";
|
|
4
4
|
export { formatSize, humanSize, parseSize, sizeFromSource } from "./files.js";
|
|
5
5
|
export { locatorFromSpec } from "./build.js";
|
|
6
|
-
export type { Expectation, FileEntry, GeneratedFileSpec, LocatorCall, LocatorSpec, PageExpectation, ParsedStep, ParsedTest, Step, TextMatcher, TextValue, UnparsedTest, } from "./model.js";
|
|
6
|
+
export type { Expectation, FileEntry, GeneratedFileSpec, LocatorCall, LocatorSpec, PageExpectation, ParsedStep, ParsedTest, Step, TextMatcher, TestInput, TextValue, UnparsedTest, } from "./model.js";
|