@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
package/dist/steps/model.d.ts
CHANGED
|
@@ -39,6 +39,11 @@ export type TextValue = {
|
|
|
39
39
|
} | {
|
|
40
40
|
kind: "secret";
|
|
41
41
|
name: string;
|
|
42
|
+
}
|
|
43
|
+
/** One of the test's declared `inputs`, `inputs.name` */
|
|
44
|
+
| {
|
|
45
|
+
kind: "input";
|
|
46
|
+
name: string;
|
|
42
47
|
};
|
|
43
48
|
/** What an assertion expects of text: a string or a regular expression */
|
|
44
49
|
export type TextMatcher = string | {
|
|
@@ -95,15 +100,33 @@ export type Step = {
|
|
|
95
100
|
} | {
|
|
96
101
|
kind: "click";
|
|
97
102
|
locator: LocatorSpec;
|
|
98
|
-
}
|
|
103
|
+
}
|
|
104
|
+
/** `output` saves the typed value as that output, `{ output: "name" }` */
|
|
105
|
+
| {
|
|
99
106
|
kind: "fill";
|
|
100
107
|
locator: LocatorSpec;
|
|
101
108
|
value: TextValue;
|
|
109
|
+
output?: string;
|
|
102
110
|
} | {
|
|
103
111
|
kind: "expect";
|
|
104
112
|
locator: LocatorSpec;
|
|
105
113
|
not: boolean;
|
|
106
114
|
expectation: Expectation;
|
|
115
|
+
/**
|
|
116
|
+
* Saves what passed as that output. Read only with `toHaveText`, `toContainText`, `toHaveValue` and
|
|
117
|
+
* `toHaveAttribute`, never with `not`: any other shape is `custom`
|
|
118
|
+
*/
|
|
119
|
+
output?: string;
|
|
120
|
+
}
|
|
121
|
+
/** Saves an element's text as an output, `outputs.save(name, locator, /pattern/)`: the first group, else the whole match */
|
|
122
|
+
| {
|
|
123
|
+
kind: "saveOutput";
|
|
124
|
+
name: string;
|
|
125
|
+
locator: LocatorSpec;
|
|
126
|
+
pattern?: {
|
|
127
|
+
source: string;
|
|
128
|
+
flags: string;
|
|
129
|
+
};
|
|
107
130
|
}
|
|
108
131
|
/** An assertion on the page, `expect(page).toHaveURL(…)` */
|
|
109
132
|
| {
|
|
@@ -187,6 +210,22 @@ export interface ParsedTest {
|
|
|
187
210
|
modifier?: Modifier;
|
|
188
211
|
/** The `{ reason }` written with the `skip` or `fixme` that applies, when it is a string literal */
|
|
189
212
|
reason?: string;
|
|
213
|
+
/** The `inputs` of the test's options object, in source order; undefined without the key */
|
|
214
|
+
inputs?: TestInput[];
|
|
215
|
+
/** The `outputs` of the test's options object; undefined without the key */
|
|
216
|
+
outputs?: string[];
|
|
217
|
+
/** The range of the test's options object (`test(title, { … }, fn)`), when it has one */
|
|
218
|
+
options?: {
|
|
219
|
+
start: number;
|
|
220
|
+
end: number;
|
|
221
|
+
};
|
|
222
|
+
/** Offset just after the title argument, where an options object goes */
|
|
223
|
+
titleEnd: number;
|
|
224
|
+
}
|
|
225
|
+
/** A declared input and the value it gets when nothing supplies one */
|
|
226
|
+
export interface TestInput {
|
|
227
|
+
name: string;
|
|
228
|
+
default: string;
|
|
190
229
|
}
|
|
191
230
|
/** A `test` or `describe` call the codec can't read: its title isn't a plain string, or its function isn't written inline */
|
|
192
231
|
export interface UnparsedTest {
|
|
@@ -194,5 +233,6 @@ export interface UnparsedTest {
|
|
|
194
233
|
/** The statement's range, as for `ParsedTest` */
|
|
195
234
|
start: number;
|
|
196
235
|
end: number;
|
|
197
|
-
|
|
236
|
+
/** `dynamic-options`: the options object isn't a literal the codec can read (`{ inputs: defaults }`, a variable) */
|
|
237
|
+
reason: "dynamic-title" | "not-inline" | "dynamic-options";
|
|
198
238
|
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
2
|
import type { Expectation, PageExpectation, TextValue } from "../model.js";
|
|
3
|
-
/** `env.NAME`, `
|
|
3
|
+
/** `env.NAME`, `secrets.NAME`, `inputs.NAME` (or `env["NAME"]` and so on) or a string literal */
|
|
4
4
|
export declare function readValue(node: ts.Expression | undefined): TextValue | undefined;
|
|
5
|
+
/** `/source/flags` */
|
|
6
|
+
export declare function readRegex(node: ts.Expression | undefined): {
|
|
7
|
+
source: string;
|
|
8
|
+
flags: string;
|
|
9
|
+
} | undefined;
|
|
10
|
+
/** The matchers that take `{ output }` as their last argument */
|
|
11
|
+
export declare const OUTPUT_MATCHERS: readonly string[];
|
|
5
12
|
export declare function readExpectation(matcher: string, args: readonly ts.Expression[]): Expectation | undefined;
|
|
6
13
|
export declare function readPageExpectation(matcher: string, args: readonly ts.Expression[]): PageExpectation | undefined;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
2
|
import { stringOf } from "./literals.js";
|
|
3
3
|
const TEXT_MATCHERS = ["toHaveText", "toContainText", "toHaveValue"];
|
|
4
|
-
/** `env.NAME`, `
|
|
4
|
+
/** `env.NAME`, `secrets.NAME`, `inputs.NAME` (or `env["NAME"]` and so on) or a string literal */
|
|
5
5
|
export function readValue(node) {
|
|
6
6
|
if (!node)
|
|
7
7
|
return undefined;
|
|
@@ -26,25 +26,31 @@ export function readValue(node) {
|
|
|
26
26
|
return { kind: "env", name };
|
|
27
27
|
if (object.text === "secrets")
|
|
28
28
|
return { kind: "secret", name };
|
|
29
|
+
if (object.text === "inputs")
|
|
30
|
+
return { kind: "input", name };
|
|
29
31
|
return undefined;
|
|
30
32
|
}
|
|
33
|
+
/** `/source/flags` */
|
|
34
|
+
export function readRegex(node) {
|
|
35
|
+
if (!node || !ts.isRegularExpressionLiteral(node))
|
|
36
|
+
return undefined;
|
|
37
|
+
const end = node.text.lastIndexOf("/");
|
|
38
|
+
return { source: node.text.slice(1, end), flags: node.text.slice(end + 1) };
|
|
39
|
+
}
|
|
31
40
|
function readTextMatcher(node) {
|
|
32
41
|
if (!node)
|
|
33
42
|
return undefined;
|
|
34
43
|
const literal = stringOf(node);
|
|
35
44
|
if (literal !== undefined)
|
|
36
45
|
return literal;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
regex: {
|
|
41
|
-
source: node.text.slice(1, end),
|
|
42
|
-
flags: node.text.slice(end + 1),
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
return undefined;
|
|
46
|
+
const regex = readRegex(node);
|
|
47
|
+
return regex && { regex };
|
|
47
48
|
}
|
|
49
|
+
/** The matchers that take `{ output }` as their last argument */
|
|
50
|
+
export const OUTPUT_MATCHERS = [
|
|
51
|
+
...TEXT_MATCHERS,
|
|
52
|
+
"toHaveAttribute",
|
|
53
|
+
];
|
|
48
54
|
export function readExpectation(matcher, args) {
|
|
49
55
|
if (matcher === "toBeVisible" ||
|
|
50
56
|
matcher === "toBeHidden" ||
|
package/dist/steps/parse/step.js
CHANGED
|
@@ -1,8 +1,58 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
2
|
import { readLocator } from "./call.js";
|
|
3
|
-
import { readExpectation, readPageExpectation, readValue, } from "./expectation.js";
|
|
3
|
+
import { OUTPUT_MATCHERS, readExpectation, readPageExpectation, readRegex, readValue, } from "./expectation.js";
|
|
4
4
|
import { readFiles } from "./file-entry.js";
|
|
5
5
|
import { readObject, stringOf } from "./literals.js";
|
|
6
|
+
/**
|
|
7
|
+
* The name in a trailing `{ output: "name" }`: undefined without one, null when the argument is there but
|
|
8
|
+
* isn't exactly that
|
|
9
|
+
*/
|
|
10
|
+
function outputOf(node) {
|
|
11
|
+
if (!node)
|
|
12
|
+
return undefined;
|
|
13
|
+
const output = readObject(node, ["output"])?.output;
|
|
14
|
+
return typeof output === "string" ? output : null;
|
|
15
|
+
}
|
|
16
|
+
/** `outputs.save("name", locator)` or `outputs.save("name", locator, /pattern/)` */
|
|
17
|
+
function readSaveOutput(args) {
|
|
18
|
+
const name = stringOf(args[0]);
|
|
19
|
+
const locator = args[1] && readLocator(args[1]);
|
|
20
|
+
const pattern = readRegex(args[2]);
|
|
21
|
+
if (name === undefined ||
|
|
22
|
+
!locator ||
|
|
23
|
+
args.length > 3 ||
|
|
24
|
+
(args.length === 3 && !pattern))
|
|
25
|
+
return undefined;
|
|
26
|
+
return {
|
|
27
|
+
kind: "saveOutput",
|
|
28
|
+
name,
|
|
29
|
+
locator,
|
|
30
|
+
...(pattern && { pattern }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** `expect(locator)…matcher(…)`, with a trailing `{ output }` only on the matchers that take one and never with `not` */
|
|
34
|
+
function readLocatorExpect(subject, negated, matcher, args) {
|
|
35
|
+
const locator = readLocator(subject);
|
|
36
|
+
if (!locator)
|
|
37
|
+
return undefined;
|
|
38
|
+
const last = args[args.length - 1];
|
|
39
|
+
const takesOutput = OUTPUT_MATCHERS.includes(matcher) &&
|
|
40
|
+
!!last &&
|
|
41
|
+
ts.isObjectLiteralExpression(last);
|
|
42
|
+
const output = takesOutput ? outputOf(last) : undefined;
|
|
43
|
+
if (output === null || (output !== undefined && negated))
|
|
44
|
+
return undefined;
|
|
45
|
+
const expectation = readExpectation(matcher, takesOutput ? args.slice(0, -1) : args);
|
|
46
|
+
return expectation
|
|
47
|
+
? {
|
|
48
|
+
kind: "expect",
|
|
49
|
+
locator,
|
|
50
|
+
not: negated,
|
|
51
|
+
expectation,
|
|
52
|
+
...(output !== undefined && { output }),
|
|
53
|
+
}
|
|
54
|
+
: undefined;
|
|
55
|
+
}
|
|
6
56
|
/** The step a statement stands for, or undefined when it is outside the recorder's set */
|
|
7
57
|
export function readStep(statement) {
|
|
8
58
|
if (!ts.isExpressionStatement(statement) ||
|
|
@@ -58,12 +108,10 @@ export function readStep(statement) {
|
|
|
58
108
|
? { kind: "expectPage", not: negated, expectation }
|
|
59
109
|
: undefined;
|
|
60
110
|
}
|
|
61
|
-
|
|
62
|
-
const expectation = readExpectation(name.text, args);
|
|
63
|
-
return locator && expectation
|
|
64
|
-
? { kind: "expect", locator, not: negated, expectation }
|
|
65
|
-
: undefined;
|
|
111
|
+
return readLocatorExpect(arg, negated, name.text, args);
|
|
66
112
|
}
|
|
113
|
+
if (ts.isIdentifier(target) && target.text === "outputs")
|
|
114
|
+
return name.text === "save" ? readSaveOutput(args) : undefined;
|
|
67
115
|
const locator = readLocator(target);
|
|
68
116
|
if (!locator)
|
|
69
117
|
return undefined;
|
|
@@ -73,9 +121,17 @@ export function readStep(statement) {
|
|
|
73
121
|
const key = stringOf(args[0]);
|
|
74
122
|
return key === undefined ? undefined : { kind: "press", locator, key };
|
|
75
123
|
}
|
|
76
|
-
if (name.text === "fill" && args.length === 1) {
|
|
124
|
+
if (name.text === "fill" && (args.length === 1 || args.length === 2)) {
|
|
77
125
|
const value = readValue(args[0]);
|
|
78
|
-
|
|
126
|
+
const output = outputOf(args[1]);
|
|
127
|
+
return value && output !== null
|
|
128
|
+
? {
|
|
129
|
+
kind: "fill",
|
|
130
|
+
locator,
|
|
131
|
+
value,
|
|
132
|
+
...(output !== undefined && { output }),
|
|
133
|
+
}
|
|
134
|
+
: undefined;
|
|
79
135
|
}
|
|
80
136
|
if ((name.text === "setInputFiles" || name.text === "dropFiles") &&
|
|
81
137
|
args.length === 1) {
|
package/dist/steps/parse.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
|
-
import {
|
|
2
|
+
import { stringOf } from "./parse/literals.js";
|
|
3
3
|
import { readStep } from "./parse/step.js";
|
|
4
|
+
import { readTestOptions } from "./test-options.js";
|
|
4
5
|
function parseStatement(source, statement, sourceFile) {
|
|
5
6
|
const start = statement.getStart(sourceFile);
|
|
6
7
|
const end = statement.end;
|
|
@@ -58,21 +59,19 @@ function calleeOf(expression) {
|
|
|
58
59
|
return undefined;
|
|
59
60
|
}
|
|
60
61
|
/**
|
|
61
|
-
* The callback of `test(title, fn)` and `describe(title, fn)`, and
|
|
62
|
-
*
|
|
62
|
+
* The callback of `test(title, fn)` and `describe(title, fn)`, and of `(title, options, fn)` with what the
|
|
63
|
+
* options say and where they sit. Undefined when the options object isn't one the codec can read
|
|
63
64
|
*/
|
|
64
|
-
function
|
|
65
|
+
function argumentsOf(node, kind, modifier) {
|
|
65
66
|
const [, second, third] = node.arguments;
|
|
66
|
-
if (
|
|
67
|
-
second
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
return { callback: second };
|
|
67
|
+
if (node.arguments.length < 3)
|
|
68
|
+
return { callback: second };
|
|
69
|
+
const options = readTestOptions(second, kind, modifier);
|
|
70
|
+
return (options && {
|
|
71
|
+
callback: third,
|
|
72
|
+
options,
|
|
73
|
+
range: { start: second.getStart(), end: second.end },
|
|
74
|
+
});
|
|
76
75
|
}
|
|
77
76
|
/** What the runner applies to a test (see `modifierOf` in the runner): the innermost `skip` or `fixme` with its reason, else `only` */
|
|
78
77
|
function effectiveMark(own, describes) {
|
|
@@ -128,12 +127,18 @@ export function parseSpecFile(source) {
|
|
|
128
127
|
!(callee.name === "test" && isConditionForm(node, callee))) {
|
|
129
128
|
const kind = callee.name;
|
|
130
129
|
const title = stringOf(node.arguments[0]);
|
|
131
|
-
const { callback, reason } = callbackOf(node, callee.modifier);
|
|
132
|
-
const body = callbackBody(callback);
|
|
133
130
|
if (title === undefined) {
|
|
134
131
|
unparsed.push(unreadable(node, kind, "dynamic-title"));
|
|
135
132
|
return;
|
|
136
133
|
}
|
|
134
|
+
const args = argumentsOf(node, kind, callee.modifier);
|
|
135
|
+
if (!args) {
|
|
136
|
+
unparsed.push(unreadable(node, kind, "dynamic-options"));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const { callback, options = {}, range } = args;
|
|
140
|
+
const { reason, inputs, outputs } = options;
|
|
141
|
+
const body = callbackBody(callback);
|
|
137
142
|
if (!body) {
|
|
138
143
|
unparsed.push(unreadable(node, kind, "not-inline"));
|
|
139
144
|
return;
|
|
@@ -154,6 +159,10 @@ export function parseSpecFile(source) {
|
|
|
154
159
|
removable: isRemovable(node),
|
|
155
160
|
fixtures: callbackFixtures(callback, sourceFile),
|
|
156
161
|
...effectiveMark(mark, describes),
|
|
162
|
+
...(inputs && { inputs }),
|
|
163
|
+
...(outputs && { outputs }),
|
|
164
|
+
...(range && { options: range }),
|
|
165
|
+
titleEnd: node.arguments[0].end,
|
|
157
166
|
});
|
|
158
167
|
return;
|
|
159
168
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { Modifier } from "../types/test.js";
|
|
3
|
+
import type { ParsedTest, TestInput } from "./model.js";
|
|
4
|
+
/** What a `test()` or `describe()` options object says, as far as the codec reads it */
|
|
5
|
+
export interface TestOptionsRead {
|
|
6
|
+
reason?: string;
|
|
7
|
+
inputs?: TestInput[];
|
|
8
|
+
outputs?: string[];
|
|
9
|
+
}
|
|
10
|
+
type ListKey = "inputs" | "outputs";
|
|
11
|
+
/**
|
|
12
|
+
* The options object of a test or describe call: a describe takes only `reason`, a test also `inputs` and
|
|
13
|
+
* `outputs`. Undefined when the object isn't a literal the codec can read, so the call is left unparsed. An
|
|
14
|
+
* empty list is left out, as the runtime leaves it out. A `reason` counts only for skip and fixme, and one that
|
|
15
|
+
* isn't a string literal is passed over, as before
|
|
16
|
+
*/
|
|
17
|
+
export declare function readTestOptions(node: ts.Expression, kind: "test" | "describe", modifier: Modifier | undefined): TestOptionsRead | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Sets one list of the test's options object: replaces it, adds it (making the object when there is none) or,
|
|
20
|
+
* for an empty list, removes it, and the whole object when nothing but empty lists would be left. The rest of
|
|
21
|
+
* the object, its layout and a `reason`, stays as it is; an unchanged list leaves the source as it is. Throws for
|
|
22
|
+
* names the runtime rejects, before writing
|
|
23
|
+
*/
|
|
24
|
+
export declare function declareList(source: string, parsed: ParsedTest, key: ListKey, list: TestInput[] | string[]): string;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { isIoName } from "../flows/core/steps.js";
|
|
3
|
+
import { IDENTIFIER } from "./emit.js";
|
|
4
|
+
import { stringOf } from "./parse/literals.js";
|
|
5
|
+
import { indentOf, splice } from "./text.js";
|
|
6
|
+
function keyOf(name) {
|
|
7
|
+
return ts.isIdentifier(name) || ts.isStringLiteral(name)
|
|
8
|
+
? name.text
|
|
9
|
+
: undefined;
|
|
10
|
+
}
|
|
11
|
+
/** `{ name: "default", … }`; undefined for anything but string literals under plain keys */
|
|
12
|
+
function readInputs(node) {
|
|
13
|
+
if (!ts.isObjectLiteralExpression(node))
|
|
14
|
+
return undefined;
|
|
15
|
+
const inputs = [];
|
|
16
|
+
for (const property of node.properties) {
|
|
17
|
+
if (!ts.isPropertyAssignment(property))
|
|
18
|
+
return undefined;
|
|
19
|
+
const name = keyOf(property.name);
|
|
20
|
+
const value = stringOf(property.initializer);
|
|
21
|
+
if (name === undefined || value === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
inputs.push({ name, default: value });
|
|
24
|
+
}
|
|
25
|
+
return inputs;
|
|
26
|
+
}
|
|
27
|
+
/** `["name", …]`; undefined for anything but string literals */
|
|
28
|
+
function readOutputs(node) {
|
|
29
|
+
if (!ts.isArrayLiteralExpression(node))
|
|
30
|
+
return undefined;
|
|
31
|
+
const names = node.elements.map(stringOf);
|
|
32
|
+
return names.every((name) => name !== undefined)
|
|
33
|
+
? names
|
|
34
|
+
: undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The options object of a test or describe call: a describe takes only `reason`, a test also `inputs` and
|
|
38
|
+
* `outputs`. Undefined when the object isn't a literal the codec can read, so the call is left unparsed. An
|
|
39
|
+
* empty list is left out, as the runtime leaves it out. A `reason` counts only for skip and fixme, and one that
|
|
40
|
+
* isn't a string literal is passed over, as before
|
|
41
|
+
*/
|
|
42
|
+
export function readTestOptions(node, kind, modifier) {
|
|
43
|
+
if (!ts.isObjectLiteralExpression(node))
|
|
44
|
+
return undefined;
|
|
45
|
+
const read = {};
|
|
46
|
+
for (const property of node.properties) {
|
|
47
|
+
if (!ts.isPropertyAssignment(property))
|
|
48
|
+
return undefined;
|
|
49
|
+
const key = keyOf(property.name);
|
|
50
|
+
const value = property.initializer;
|
|
51
|
+
if (key === "reason") {
|
|
52
|
+
const reason = stringOf(value);
|
|
53
|
+
if (reason !== undefined && (modifier === "skip" || modifier === "fixme"))
|
|
54
|
+
read.reason = reason;
|
|
55
|
+
}
|
|
56
|
+
else if (kind === "test" && key === "inputs") {
|
|
57
|
+
const inputs = readInputs(value);
|
|
58
|
+
if (!inputs)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (inputs.length > 0)
|
|
61
|
+
read.inputs = inputs;
|
|
62
|
+
}
|
|
63
|
+
else if (kind === "test" && key === "outputs") {
|
|
64
|
+
const outputs = readOutputs(value);
|
|
65
|
+
if (!outputs)
|
|
66
|
+
return undefined;
|
|
67
|
+
if (outputs.length > 0)
|
|
68
|
+
read.outputs = outputs;
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return read;
|
|
74
|
+
}
|
|
75
|
+
const str = (value) => JSON.stringify(value);
|
|
76
|
+
function listToSource(key, list) {
|
|
77
|
+
if (key === "outputs")
|
|
78
|
+
return `[${list.map(str).join(", ")}]`;
|
|
79
|
+
const entries = list.map(({ name, default: value }) => `${IDENTIFIER.test(name) ? name : str(name)}: ${str(value)}`);
|
|
80
|
+
return `{ ${entries.join(", ")} }`;
|
|
81
|
+
}
|
|
82
|
+
const sameList = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
83
|
+
/** Throws for a list the runtime's `test()` would reject: a name that isn't valid, a name twice, a default that isn't text */
|
|
84
|
+
function checkList(key, list) {
|
|
85
|
+
const kind = key === "inputs" ? "input" : "output";
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
for (const entry of list) {
|
|
88
|
+
const name = typeof entry === "string" ? entry : entry.name;
|
|
89
|
+
if (!isIoName(name))
|
|
90
|
+
throw new Error(`applyEdit(): ${kind} ${JSON.stringify(name)} is not a valid name`);
|
|
91
|
+
if (seen.has(name))
|
|
92
|
+
throw new Error(`applyEdit(): ${kind} "${name}" is listed twice`);
|
|
93
|
+
seen.add(name);
|
|
94
|
+
if (typeof entry !== "string" && typeof entry.default !== "string")
|
|
95
|
+
throw new Error(`applyEdit(): input "${name}" needs a string default`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** The options object at `range`, parsed on its own, with offsets mapped back into the source */
|
|
99
|
+
function optionsObject(source, range) {
|
|
100
|
+
const file = ts.createSourceFile("options.ts", `(${source.slice(range.start, range.end)})`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
101
|
+
const statement = file.statements[0];
|
|
102
|
+
const object = statement.expression
|
|
103
|
+
.expression;
|
|
104
|
+
const at = (offset) => offset - 1 + range.start;
|
|
105
|
+
return object.properties.map((property) => {
|
|
106
|
+
const assignment = property;
|
|
107
|
+
return {
|
|
108
|
+
key: keyOf(assignment.name),
|
|
109
|
+
start: at(assignment.getStart(file)),
|
|
110
|
+
end: at(assignment.end),
|
|
111
|
+
valueStart: at(assignment.initializer.getStart(file)),
|
|
112
|
+
emptyList: (ts.isObjectLiteralExpression(assignment.initializer) &&
|
|
113
|
+
assignment.initializer.properties.length === 0) ||
|
|
114
|
+
(ts.isArrayLiteralExpression(assignment.initializer) &&
|
|
115
|
+
assignment.initializer.elements.length === 0),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Sets one list of the test's options object: replaces it, adds it (making the object when there is none) or,
|
|
121
|
+
* for an empty list, removes it, and the whole object when nothing but empty lists would be left. The rest of
|
|
122
|
+
* the object, its layout and a `reason`, stays as it is; an unchanged list leaves the source as it is. Throws for
|
|
123
|
+
* names the runtime rejects, before writing
|
|
124
|
+
*/
|
|
125
|
+
export function declareList(source, parsed, key, list) {
|
|
126
|
+
checkList(key, list);
|
|
127
|
+
if (list.length > 0 && sameList(parsed[key], list))
|
|
128
|
+
return source;
|
|
129
|
+
const text = `${key}: ${listToSource(key, list)}`;
|
|
130
|
+
if (!parsed.options)
|
|
131
|
+
return list.length === 0
|
|
132
|
+
? source
|
|
133
|
+
: splice(source, parsed.titleEnd, parsed.titleEnd, `, { ${text} }`);
|
|
134
|
+
const properties = optionsObject(source, parsed.options);
|
|
135
|
+
const index = properties.findIndex((property) => property.key === key);
|
|
136
|
+
const property = properties[index];
|
|
137
|
+
if (list.length === 0) {
|
|
138
|
+
// An empty list that is there still goes (the parse leaves it out), so an object of only empty lists is removed
|
|
139
|
+
if (!property)
|
|
140
|
+
return source;
|
|
141
|
+
const others = properties.filter((_, i) => i !== index);
|
|
142
|
+
if (others.every((other) => other.emptyList))
|
|
143
|
+
return splice(source, parsed.titleEnd, parsed.options.end, "");
|
|
144
|
+
return index > 0
|
|
145
|
+
? splice(source, properties[index - 1].end, property.end, "")
|
|
146
|
+
: splice(source, property.start, properties[1].start, "");
|
|
147
|
+
}
|
|
148
|
+
if (property)
|
|
149
|
+
return splice(source, property.valueStart, property.end, listToSource(key, list));
|
|
150
|
+
const last = properties[properties.length - 1];
|
|
151
|
+
if (!last)
|
|
152
|
+
return splice(source, parsed.options.start, parsed.options.end, `{ ${text} }`);
|
|
153
|
+
const multiline = source
|
|
154
|
+
.slice(parsed.options.start, properties[0].start)
|
|
155
|
+
.includes("\n");
|
|
156
|
+
const separator = multiline ? `\n${indentOf(source, last.start)}` : " ";
|
|
157
|
+
return splice(source, last.end, last.end, `,${separator}${text}`);
|
|
158
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Offset of the first character of the line `offset` is on */
|
|
2
|
+
export declare const lineStart: (source: string, offset: number) => number;
|
|
3
|
+
/** Offset just after the newline that ends the line `offset` is on, or the end of the source */
|
|
4
|
+
export declare function lineEnd(source: string, offset: number): number;
|
|
5
|
+
/** The spaces and tabs that start the line `offset` is on */
|
|
6
|
+
export declare const indentOf: (source: string, offset: number) => string;
|
|
7
|
+
export declare function splice(source: string, from: number, to: number, text: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Offset of the first character of the line `offset` is on */
|
|
2
|
+
export const lineStart = (source, offset) => source.lastIndexOf("\n", offset - 1) + 1;
|
|
3
|
+
/** Offset just after the newline that ends the line `offset` is on, or the end of the source */
|
|
4
|
+
export function lineEnd(source, offset) {
|
|
5
|
+
const newline = source.indexOf("\n", offset);
|
|
6
|
+
return newline === -1 ? source.length : newline + 1;
|
|
7
|
+
}
|
|
8
|
+
/** The spaces and tabs that start the line `offset` is on */
|
|
9
|
+
export const indentOf = (source, offset) => /^[ \t]*/.exec(source.slice(lineStart(source, offset)))[0];
|
|
10
|
+
export function splice(source, from, to, text) {
|
|
11
|
+
return source.slice(0, from) + text + source.slice(to);
|
|
12
|
+
}
|
package/dist/types/test.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Browser } from "../browser/browser.js";
|
|
2
2
|
import type Page from "../browser/page.js";
|
|
3
|
+
import type Locator from "../locator/locator.js";
|
|
3
4
|
import type { SupportedBrowser } from "./browser.js";
|
|
4
5
|
|
|
5
6
|
/** What a test function receives */
|
|
@@ -14,6 +15,19 @@ export interface TestFixtures {
|
|
|
14
15
|
env: Readonly<Record<string, string | number | boolean>>;
|
|
15
16
|
/** Secrets by name (SAMURAI_SECRET_<NAME>); reading an unset name throws */
|
|
16
17
|
secrets: Readonly<Record<string, string>>;
|
|
18
|
+
/** The inputs the test declared: the value the run supplies, else the default; reading another name throws */
|
|
19
|
+
inputs: Readonly<Record<string, string>>;
|
|
20
|
+
/** Saves the text values the test declared as outputs */
|
|
21
|
+
outputs: TestOutputs;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The `outputs` fixture */
|
|
25
|
+
export interface TestOutputs {
|
|
26
|
+
/**
|
|
27
|
+
* Waits up to the expect timeout for the element to have text and saves its trimmed text as output `name`.
|
|
28
|
+
* With `pattern`, waits until the text matches and saves the first capture group, else the whole match.
|
|
29
|
+
*/
|
|
30
|
+
save(name: string, locator: Locator, pattern?: RegExp): Promise<void>;
|
|
17
31
|
}
|
|
18
32
|
|
|
19
33
|
export type TestCase = TestCaseBase;
|
|
@@ -27,6 +41,14 @@ export interface TestDetails {
|
|
|
27
41
|
reason?: string;
|
|
28
42
|
}
|
|
29
43
|
|
|
44
|
+
/** The optional second argument of `test`, `test.only`, `test.skip` and `test.fixme` */
|
|
45
|
+
export interface TestOptions extends TestDetails {
|
|
46
|
+
/** Named text values the test reads, each with the default it gets when nothing supplies one */
|
|
47
|
+
inputs?: Record<string, string>;
|
|
48
|
+
/** Names of the text values the test saves for later tests to use */
|
|
49
|
+
outputs?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
30
52
|
/** Runs before or after each test of a suite, in the test's browser, with the test's fixtures */
|
|
31
53
|
export type EachHook = (fixtures: TestFixtures) => Promise<void> | void;
|
|
32
54
|
|
|
@@ -64,6 +86,10 @@ export interface RegisteredTestCase extends TestCaseBase {
|
|
|
64
86
|
modifier?: Modifier;
|
|
65
87
|
/** The reason given with the `skip` or `fixme` that applies */
|
|
66
88
|
reason?: string;
|
|
89
|
+
/** The inputs the test declared, with their defaults */
|
|
90
|
+
inputs?: Record<string, string>;
|
|
91
|
+
/** The names of the outputs the test declared */
|
|
92
|
+
outputs?: string[];
|
|
67
93
|
}
|
|
68
94
|
|
|
69
95
|
export interface TestCaseBase {
|
|
@@ -88,6 +114,10 @@ interface TestResultSuccess extends TestResultIdentity, TestRetries {
|
|
|
88
114
|
logsDropped?: number;
|
|
89
115
|
/** Named screenshots the test saved, relative to the result folder */
|
|
90
116
|
screenshots?: { name: string; path: string }[];
|
|
117
|
+
/** The inputs the test declared, with the values this attempt read; absent when it declares none */
|
|
118
|
+
inputs?: Record<string, string>;
|
|
119
|
+
/** The outputs the test saved; absent when it declares none */
|
|
120
|
+
outputs?: Record<string, string>;
|
|
91
121
|
}
|
|
92
122
|
|
|
93
123
|
/** The error sits flat on the entry, as in the report: `message`, `type`, `stack`, and for assertions `expected` and `actual` */
|
|
@@ -98,6 +128,10 @@ interface TestResultFailed extends TestResultIdentity, TestError, TestRetries {
|
|
|
98
128
|
logsDropped?: number;
|
|
99
129
|
/** Named screenshots the test saved, relative to the result folder */
|
|
100
130
|
screenshots?: { name: string; path: string }[];
|
|
131
|
+
/** The inputs the test declared, with the values this attempt read; absent when it declares none */
|
|
132
|
+
inputs?: Record<string, string>;
|
|
133
|
+
/** The outputs the test saved; absent when it declares none */
|
|
134
|
+
outputs?: Record<string, string>;
|
|
101
135
|
}
|
|
102
136
|
|
|
103
137
|
/** What a result says about the retries of its test; absent when it passed or failed on the first attempt */
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where one attempt of a test keeps the outputs it saves. Once closed it ignores what still arrives, so a
|
|
3
|
+
* capture that outlives its test (it timed out, or nobody awaited it) never lands in the next one.
|
|
4
|
+
*/
|
|
5
|
+
export interface OutputSink {
|
|
6
|
+
/** Whether the test declared `name` */
|
|
7
|
+
declares(name: string): boolean;
|
|
8
|
+
/** Keeps `value` under `name`; throws for a name the test did not declare, ignores it once closed */
|
|
9
|
+
set(name: string, value: string): void;
|
|
10
|
+
/** What was saved so far */
|
|
11
|
+
values(): Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
/** Opens the sink of an attempt that declared `declared`, closing any one still open */
|
|
14
|
+
export declare function beginOutputs(declared: readonly string[]): OutputSink;
|
|
15
|
+
/** The open sink, or none outside a test. A capture takes it when it starts and saves into that one */
|
|
16
|
+
export declare function currentOutputSink(): OutputSink | undefined;
|
|
17
|
+
/** Closes the open sink and returns what it holds */
|
|
18
|
+
export declare function endOutputs(): Record<string, string>;
|
|
19
|
+
/**
|
|
20
|
+
* Takes the open sink for a capture that starts now (`method` names the call in the errors) and returns the
|
|
21
|
+
* function that saves `value` under `name` into it. The sink is bound here: a value that arrives after the test
|
|
22
|
+
* ended goes to its own closed sink and is ignored.
|
|
23
|
+
*/
|
|
24
|
+
export declare function bindOutput(method: string, name: string): (value: string) => void;
|