@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
|
@@ -26,6 +26,10 @@ export async function toolbarOptions(run, source, dir, config, testIndex) {
|
|
|
26
26
|
variables: run.settings.variables,
|
|
27
27
|
secrets,
|
|
28
28
|
secretsIgnored: await isGitIgnored(dir, `.env.${environment}`),
|
|
29
|
+
inputs: Object.fromEntries((tests[testIndex]?.inputs ?? []).map((input) => [
|
|
30
|
+
input.name,
|
|
31
|
+
input.default,
|
|
32
|
+
])),
|
|
29
33
|
screenshots: tests[testIndex]?.steps.filter(({ step }) => step.kind === "screenshot")
|
|
30
34
|
.length ?? 0,
|
|
31
35
|
};
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import type { ActionReply } from "./protocol.js";
|
|
2
|
-
import type { Message,
|
|
3
|
-
/** The messages that expect a reply, and that a double click sends twice */
|
|
4
|
-
type Replying = Of<"assert" | "assert-page" | "insert" | "add-insertable">;
|
|
2
|
+
import type { Message, Replying } from "./types.js";
|
|
5
3
|
/**
|
|
6
4
|
* Drops the repeat of an action that is running or was answered `{ ok: true }` (a double click: its first copy
|
|
7
5
|
* answers), and lets a retry after `{ ok: false }` through.
|
|
8
6
|
*/
|
|
9
7
|
export declare class ActionGuard {
|
|
10
|
-
/** Ids of
|
|
8
|
+
/** Ids of the replying actions (`REPLYING`) being processed */
|
|
11
9
|
private readonly inFlight;
|
|
12
10
|
/** Ids `accept` let through whose `run` hasn't started yet */
|
|
13
11
|
private readonly claimed;
|
|
@@ -18,4 +16,3 @@ export declare class ActionGuard {
|
|
|
18
16
|
/** Runs an action that expects a reply, unless it is a repeat (see `accept`); `reply` gets the answer */
|
|
19
17
|
run(message: Replying, work: () => Promise<ActionReply>, reply: (id: number, result: ActionReply) => void): Promise<void>;
|
|
20
18
|
}
|
|
21
|
-
export {};
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
const REPLYING = [
|
|
2
|
+
"assert",
|
|
3
|
+
"assert-page",
|
|
4
|
+
"insert",
|
|
5
|
+
"add-insertable",
|
|
6
|
+
"save-output",
|
|
7
|
+
];
|
|
1
8
|
/**
|
|
2
9
|
* Drops the repeat of an action that is running or was answered `{ ok: true }` (a double click: its first copy
|
|
3
10
|
* answers), and lets a retry after `{ ok: false }` through.
|
|
4
11
|
*/
|
|
5
12
|
export class ActionGuard {
|
|
6
13
|
constructor() {
|
|
7
|
-
/** Ids of
|
|
14
|
+
/** Ids of the replying actions (`REPLYING`) being processed */
|
|
8
15
|
this.inFlight = new Set();
|
|
9
16
|
/** Ids `accept` let through whose `run` hasn't started yet */
|
|
10
17
|
this.claimed = new Set();
|
|
@@ -13,10 +20,7 @@ export class ActionGuard {
|
|
|
13
20
|
}
|
|
14
21
|
/** Whether to process `message`; call it as the message arrives, before queueing it */
|
|
15
22
|
accept(message) {
|
|
16
|
-
if (message.action
|
|
17
|
-
message.action !== "assert-page" &&
|
|
18
|
-
message.action !== "insert" &&
|
|
19
|
-
message.action !== "add-insertable")
|
|
23
|
+
if (!REPLYING.includes(message.action))
|
|
20
24
|
return true;
|
|
21
25
|
const { id } = message;
|
|
22
26
|
if (this.inFlight.has(id) || this.done.has(id))
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ActionReply } from "../protocol.js";
|
|
2
2
|
import type { ActionContext, Of } from "../types.js";
|
|
3
|
-
/** Types a variable or
|
|
3
|
+
/** Types a variable, secret or input the tester already has into the field they picked, and records one `insert` step */
|
|
4
4
|
export declare function insertAction(ctx: ActionContext, message: Of<"insert">): Promise<ActionReply>;
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Adds a new variable, secret or input, then types it into the field and records the `insert` step. A variable or
|
|
7
|
+
* secret is saved to the project first; an input is declared by the step itself.
|
|
8
|
+
*/
|
|
6
9
|
export declare function addInsertableAction(ctx: ActionContext, message: Of<"add-insertable">): Promise<ActionReply>;
|
|
@@ -1,28 +1,39 @@
|
|
|
1
1
|
import { ConfigEditError } from "../../../project/config-edit.js";
|
|
2
2
|
import { SECRET_NAME } from "../../../project/secrets-file.js";
|
|
3
3
|
import { ExistsError, VARIABLE_NAME } from "../../../project/variables.js";
|
|
4
|
-
import { fail, NO_ENVIRONMENT, PAUSED, TYPE_FAILED, UNIDENTIFIABLE, } from "./reply.js";
|
|
5
|
-
/** Types a variable or
|
|
4
|
+
import { fail, nameProblem, NO_ENVIRONMENT, PAUSED, TYPE_FAILED, UNIDENTIFIABLE, } from "./reply.js";
|
|
5
|
+
/** Types a variable, secret or input the tester already has into the field they picked, and records one `insert` step */
|
|
6
6
|
export async function insertAction(ctx, message) {
|
|
7
7
|
if (ctx.isPaused())
|
|
8
8
|
return fail(PAUSED);
|
|
9
9
|
const { kind, name } = message;
|
|
10
|
-
const value = kind === "secret"
|
|
10
|
+
const value = kind === "secret"
|
|
11
|
+
? ctx.secrets.get(name)
|
|
12
|
+
: kind === "input"
|
|
13
|
+
? ctx.inputs.get(name)
|
|
14
|
+
: ctx.variables.get(name);
|
|
11
15
|
if (value === undefined)
|
|
12
16
|
return fail(kind === "secret"
|
|
13
17
|
? `Set a value for ${name} first`
|
|
14
|
-
:
|
|
18
|
+
: kind === "input"
|
|
19
|
+
? `There is no input "${name}"`
|
|
20
|
+
: `There is no variable "${name}"`);
|
|
15
21
|
const locator = await locate(ctx, message);
|
|
16
22
|
if (!locator)
|
|
17
23
|
return fail(UNIDENTIFIABLE);
|
|
18
24
|
return typeIn(ctx, locator, kind, name, value);
|
|
19
25
|
}
|
|
20
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Adds a new variable, secret or input, then types it into the field and records the `insert` step. A variable or
|
|
28
|
+
* secret is saved to the project first; an input is declared by the step itself.
|
|
29
|
+
*/
|
|
21
30
|
export async function addInsertableAction(ctx, message) {
|
|
22
31
|
if (ctx.isPaused())
|
|
23
32
|
return fail(PAUSED);
|
|
24
33
|
const { kind, name } = message;
|
|
25
34
|
const value = String(message.value ?? "");
|
|
35
|
+
if (kind === "input")
|
|
36
|
+
return addInput(ctx, message, name, value);
|
|
26
37
|
if (kind === "env" && !ctx.options.canAddVariables)
|
|
27
38
|
return fail(NO_ENVIRONMENT);
|
|
28
39
|
const known = kind === "env"
|
|
@@ -56,6 +67,26 @@ export async function addInsertableAction(ctx, message) {
|
|
|
56
67
|
ctx.push();
|
|
57
68
|
return typeIn(ctx, locator, kind, name, value, true);
|
|
58
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Types a new input's value and records the step with the declaration; the project is not written, the
|
|
72
|
+
* test's own `inputs` carries the default. The name counts as taken once the step is recorded.
|
|
73
|
+
*/
|
|
74
|
+
async function addInput(ctx, message, name, value) {
|
|
75
|
+
const problem = nameProblem(name);
|
|
76
|
+
if (problem)
|
|
77
|
+
return fail(problem, "name");
|
|
78
|
+
if (ctx.inputs.has(name))
|
|
79
|
+
return fail(`"${name}" already exists`, "name");
|
|
80
|
+
const locator = await locate(ctx, message);
|
|
81
|
+
if (!locator)
|
|
82
|
+
return fail(UNIDENTIFIABLE);
|
|
83
|
+
const reply = await typeIn(ctx, locator, "input", name, value, false, true);
|
|
84
|
+
if (reply.ok) {
|
|
85
|
+
ctx.inputs.set(name, value);
|
|
86
|
+
ctx.push();
|
|
87
|
+
}
|
|
88
|
+
return reply;
|
|
89
|
+
}
|
|
59
90
|
/** Undefined when `work` succeeded, else the reply for its error */
|
|
60
91
|
async function write(work, replyFor) {
|
|
61
92
|
try {
|
|
@@ -76,7 +107,7 @@ async function locate(ctx, message) {
|
|
|
76
107
|
return undefined; // the element went away
|
|
77
108
|
}
|
|
78
109
|
}
|
|
79
|
-
async function typeIn(ctx, locator, kind, name, value, saved = false) {
|
|
110
|
+
async function typeIn(ctx, locator, kind, name, value, saved = false, declare = false) {
|
|
80
111
|
try {
|
|
81
112
|
await ctx.deps.type(locator, value);
|
|
82
113
|
}
|
|
@@ -86,7 +117,12 @@ async function typeIn(ctx, locator, kind, name, value, saved = false) {
|
|
|
86
117
|
ctx.push();
|
|
87
118
|
return fail(TYPE_FAILED);
|
|
88
119
|
}
|
|
89
|
-
ctx.deps.observe({
|
|
120
|
+
ctx.deps.observe({
|
|
121
|
+
type: "insert",
|
|
122
|
+
locator,
|
|
123
|
+
value: { kind, name },
|
|
124
|
+
...(declare && { declare: value }),
|
|
125
|
+
});
|
|
90
126
|
return { ok: true };
|
|
91
127
|
}
|
|
92
128
|
const errorText = (err) => err instanceof Error ? err.message : String(err);
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { PickInfo } from "../protocol.js";
|
|
2
2
|
/** Whether a text holds the value of one of the secrets */
|
|
3
3
|
export declare function holdsSecret(secrets: Map<string, string | undefined>): (text: string | undefined) => boolean;
|
|
4
|
+
/** A text with every secret value it holds replaced by the mask, longest value first (as `holdsSecret` finds them) */
|
|
5
|
+
export declare function maskSecrets(secrets: Map<string, string | undefined>): (text: string) => string;
|
|
4
6
|
/**
|
|
5
7
|
* A field may hold an inserted secret: its value (and any text, attribute, class or CSS value holding a secret) is left out,
|
|
8
|
+
* the output text has the secret masked (Save as output previews on the recorder's side then, see `outputPreview`),
|
|
6
9
|
* and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
|
|
7
10
|
*/
|
|
8
11
|
export declare function withoutSecrets(info: PickInfo & {
|
|
@@ -1,17 +1,27 @@
|
|
|
1
|
+
import { MASK } from "../../../config/mask.js";
|
|
1
2
|
import { expectationFor } from "../../expectation.js";
|
|
2
3
|
/** Whether a text holds the value of one of the secrets */
|
|
3
4
|
export function holdsSecret(secrets) {
|
|
4
5
|
const values = [...secrets.values()].filter((value) => value !== undefined && value !== "");
|
|
5
6
|
return (text) => text !== undefined && values.some((value) => text.includes(value));
|
|
6
7
|
}
|
|
8
|
+
/** A text with every secret value it holds replaced by the mask, longest value first (as `holdsSecret` finds them) */
|
|
9
|
+
export function maskSecrets(secrets) {
|
|
10
|
+
const values = [...secrets.values()]
|
|
11
|
+
.filter((value) => value !== undefined && value !== "")
|
|
12
|
+
.sort((a, b) => b.length - a.length);
|
|
13
|
+
return (text) => values.reduce((masked, value) => masked.split(value).join(MASK), text);
|
|
14
|
+
}
|
|
7
15
|
/**
|
|
8
16
|
* A field may hold an inserted secret: its value (and any text, attribute, class or CSS value holding a secret) is left out,
|
|
17
|
+
* the output text has the secret masked (Save as output previews on the recorder's side then, see `outputPreview`),
|
|
9
18
|
* and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
|
|
10
19
|
*/
|
|
11
20
|
export function withoutSecrets(info, secrets) {
|
|
12
21
|
const secret = holdsSecret(secrets);
|
|
13
22
|
if (!secret(info.value) &&
|
|
14
23
|
!secret(info.text) &&
|
|
24
|
+
!secret(info.outputText) &&
|
|
15
25
|
!secret(info.first.expected) &&
|
|
16
26
|
!secret(info.className ?? undefined) &&
|
|
17
27
|
!secret(info.css.value) &&
|
|
@@ -23,6 +33,8 @@ export function withoutSecrets(info, secrets) {
|
|
|
23
33
|
return {
|
|
24
34
|
...rest,
|
|
25
35
|
text,
|
|
36
|
+
outputText: maskSecrets(secrets)(info.outputText),
|
|
37
|
+
...(secret(info.outputText) && { showsSecret: true }),
|
|
26
38
|
attributes: info.attributes.filter((a) => !secret(a.value)),
|
|
27
39
|
className: secret(info.className ?? undefined) ? null : info.className,
|
|
28
40
|
css: secret(info.css.value) ? { ...info.css, value: "" } : info.css,
|
|
@@ -5,3 +5,5 @@ export declare const NO_ENVIRONMENT = "This recording has no environment";
|
|
|
5
5
|
export declare const PAUSED = "Nothing you do now is recorded.";
|
|
6
6
|
/** A refusal the toolbar shows; `field` names the form field to mark */
|
|
7
7
|
export declare const fail: (error: string, field?: "name") => ActionReply;
|
|
8
|
+
/** What is wrong with `name` as an input or output name, as the name field shows it; undefined when nothing is */
|
|
9
|
+
export declare function nameProblem(name: string): string | undefined;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { IO_NAME, isIoName } from "../../../flows/core/steps.js";
|
|
1
2
|
export const UNIDENTIFIABLE = "This element can't be identified uniquely";
|
|
2
3
|
export const TYPE_FAILED = "Couldn't type into that field";
|
|
3
4
|
export const NO_ENVIRONMENT = "This recording has no environment";
|
|
@@ -8,3 +9,11 @@ export const fail = (error, field) => ({
|
|
|
8
9
|
error,
|
|
9
10
|
...(field && { field }),
|
|
10
11
|
});
|
|
12
|
+
/** What is wrong with `name` as an input or output name, as the name field shows it; undefined when nothing is */
|
|
13
|
+
export function nameProblem(name) {
|
|
14
|
+
if (isIoName(name))
|
|
15
|
+
return undefined;
|
|
16
|
+
return IO_NAME.test(name)
|
|
17
|
+
? `name: ${JSON.stringify(name)} is reserved`
|
|
18
|
+
: "name: use letters, digits and _";
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ActionReply, OutputPreview } from "../protocol.js";
|
|
2
|
+
import type { ActionContext, Of } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* What Save as output would save from an element that shows a secret, worked out here because the page only has
|
|
5
|
+
* its text masked: `outputs.save`'s rule (the first group, else the whole match, else the whole text) on the text
|
|
6
|
+
* as picked, every secret masked, and whether the value held one. The errors read as the panel's own preview's.
|
|
7
|
+
*/
|
|
8
|
+
export declare function outputPreview(ctx: ActionContext, message: Of<"output-preview">): OutputPreview;
|
|
9
|
+
/**
|
|
10
|
+
* Turns the Save as output panel's choice into a `saveOutput` step for the element that was picked. Refuses a
|
|
11
|
+
* name `test()` would reject, an element with no text when there is no pattern, and a pattern that isn't valid
|
|
12
|
+
* or doesn't match the element's text as picked, read the way `outputs.save` reads it. An element that shows a
|
|
13
|
+
* secret is saved too: the panel warns that the output may hold it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function saveOutputAction(ctx: ActionContext, message: Of<"save-output">): Promise<ActionReply>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { captured, matchesText } from "../../../assert/text-match.js";
|
|
2
|
+
import { holdsSecret, maskSecrets } from "./pick.js";
|
|
3
|
+
import { fail, nameProblem, PAUSED } from "./reply.js";
|
|
4
|
+
const PICK_AGAIN = "Pick the element again";
|
|
5
|
+
/** A pattern as typed, compiled the way the step writes it; undefined when there is none, an error when it isn't valid */
|
|
6
|
+
function compile(pattern) {
|
|
7
|
+
const source = String(pattern ?? "").trim();
|
|
8
|
+
if (source === "")
|
|
9
|
+
return undefined;
|
|
10
|
+
try {
|
|
11
|
+
return new RegExp(source);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { error: "pattern is not a valid regular expression" };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* What Save as output would save from an element that shows a secret, worked out here because the page only has
|
|
19
|
+
* its text masked: `outputs.save`'s rule (the first group, else the whole match, else the whole text) on the text
|
|
20
|
+
* as picked, every secret masked, and whether the value held one. The errors read as the panel's own preview's.
|
|
21
|
+
*/
|
|
22
|
+
export function outputPreview(ctx, message) {
|
|
23
|
+
const pattern = String(message.pattern ?? "");
|
|
24
|
+
const answer = { preview: true, pattern };
|
|
25
|
+
const pick = ctx.pick();
|
|
26
|
+
if (!pick || pick.id !== message.id)
|
|
27
|
+
return { ...answer, ok: false, error: PICK_AGAIN };
|
|
28
|
+
const compiled = compile(pattern);
|
|
29
|
+
if (compiled && !(compiled instanceof RegExp))
|
|
30
|
+
return { ...answer, ok: false, error: "Not a valid pattern" };
|
|
31
|
+
if (compiled && !matchesText(pick.text, compiled, "exact"))
|
|
32
|
+
return { ...answer, ok: false, error: "No match" };
|
|
33
|
+
const value = compiled ? captured(compiled, pick.text) : pick.text;
|
|
34
|
+
return {
|
|
35
|
+
...answer,
|
|
36
|
+
ok: true,
|
|
37
|
+
value: maskSecrets(ctx.secrets)(value),
|
|
38
|
+
secret: holdsSecret(ctx.secrets)(value),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Turns the Save as output panel's choice into a `saveOutput` step for the element that was picked. Refuses a
|
|
43
|
+
* name `test()` would reject, an element with no text when there is no pattern, and a pattern that isn't valid
|
|
44
|
+
* or doesn't match the element's text as picked, read the way `outputs.save` reads it. An element that shows a
|
|
45
|
+
* secret is saved too: the panel warns that the output may hold it.
|
|
46
|
+
*/
|
|
47
|
+
export async function saveOutputAction(ctx, message) {
|
|
48
|
+
if (ctx.isPaused())
|
|
49
|
+
return fail(PAUSED);
|
|
50
|
+
const pick = ctx.pick();
|
|
51
|
+
if (!pick || pick.id !== message.id)
|
|
52
|
+
return fail(PICK_AGAIN);
|
|
53
|
+
const name = String(message.name ?? "").trim();
|
|
54
|
+
const problem = nameProblem(name);
|
|
55
|
+
if (problem)
|
|
56
|
+
return fail(problem, "name");
|
|
57
|
+
const pattern = compile(message.pattern);
|
|
58
|
+
if (pattern && !(pattern instanceof RegExp))
|
|
59
|
+
return fail(pattern.error);
|
|
60
|
+
if (pattern && !matchesText(pick.text, pattern, "exact"))
|
|
61
|
+
return fail("pattern doesn't match the element's text");
|
|
62
|
+
if (!pattern && pick.text === "")
|
|
63
|
+
return fail("the element has no text");
|
|
64
|
+
ctx.setPick(undefined);
|
|
65
|
+
ctx.deps.observe({
|
|
66
|
+
type: "saveOutput",
|
|
67
|
+
locator: pick.locator,
|
|
68
|
+
name,
|
|
69
|
+
// The built pattern's source: a `/` comes out escaped, so the spec it is written into compiles
|
|
70
|
+
...(pattern && { pattern: { source: pattern.source, flags: "" } }),
|
|
71
|
+
});
|
|
72
|
+
return { ok: true };
|
|
73
|
+
}
|
|
@@ -18,8 +18,8 @@ export type Message = ToolbarMessage & {
|
|
|
18
18
|
target?: ElementHandle;
|
|
19
19
|
};
|
|
20
20
|
export declare const msg: (message: Record<string, unknown>) => Message;
|
|
21
|
-
export declare const insert: (id: number, kind: "env" | "secret", name: string) => Message;
|
|
22
|
-
export declare const add: (id: number, kind: "env" | "secret", name: string, value: string) => Message;
|
|
21
|
+
export declare const insert: (id: number, kind: "env" | "secret" | "input", name: string) => Message;
|
|
22
|
+
export declare const add: (id: number, kind: "env" | "secret" | "input", name: string, value: string) => Message;
|
|
23
23
|
export declare const pick: (id: number) => Message;
|
|
24
24
|
/** A controller over fakes that record everything */
|
|
25
25
|
export declare function setup(options?: Partial<ControllerOptions>, overrides?: Partial<ControllerDeps>): {
|
|
@@ -10,6 +10,7 @@ const OPTIONS = {
|
|
|
10
10
|
secrets: { ADMIN_PASSWORD: SECRET, API_TOKEN: undefined },
|
|
11
11
|
secretsIgnored: true,
|
|
12
12
|
screenshots: 0,
|
|
13
|
+
inputs: { email: "a@b.c" },
|
|
13
14
|
};
|
|
14
15
|
export const TARGET = new ElementHandle("el-1");
|
|
15
16
|
export const CANDIDATES = [
|
|
@@ -24,6 +25,7 @@ export const INFO = {
|
|
|
24
25
|
ok: true,
|
|
25
26
|
label: `textbox "Email"`,
|
|
26
27
|
text: "",
|
|
28
|
+
outputText: "",
|
|
27
29
|
value: "a@b.c",
|
|
28
30
|
attributes: [{ name: "id", value: "email" }],
|
|
29
31
|
first: { matcher: "toHaveValue", expected: "a@b.c" },
|
|
@@ -15,6 +15,7 @@ export declare class ToolbarController {
|
|
|
15
15
|
private screenshots;
|
|
16
16
|
private readonly variables;
|
|
17
17
|
private readonly secrets;
|
|
18
|
+
private readonly inputs;
|
|
18
19
|
private pausedAt;
|
|
19
20
|
/** The URL the resume question offers; set while it is asked */
|
|
20
21
|
private question;
|
|
@@ -70,7 +71,7 @@ export declare class ToolbarController {
|
|
|
70
71
|
private answer;
|
|
71
72
|
/**
|
|
72
73
|
* Whether to process `message`; call it as the message arrives, before queueing it. An `assert`, `assert-page`,
|
|
73
|
-
* `insert` or `
|
|
74
|
+
* `insert`, `add-insertable` or `save-output` whose id is in flight or was already answered `{ ok: true }` is a double click and is
|
|
74
75
|
* dropped (its first copy answers). A retry after `{ ok: false }` is accepted.
|
|
75
76
|
*/
|
|
76
77
|
accept(message: Message): boolean;
|
|
@@ -4,7 +4,9 @@ import { assertAction } from "./actions/assert.js";
|
|
|
4
4
|
import { assertPageAction, pageInfoOf } from "./actions/assert-page.js";
|
|
5
5
|
import { addInsertableAction, insertAction } from "./actions/insert.js";
|
|
6
6
|
import { withoutSecrets } from "./actions/pick.js";
|
|
7
|
+
import { outputPreview, saveOutputAction } from "./actions/save-output.js";
|
|
7
8
|
import { fail, PAUSED, UNIDENTIFIABLE } from "./actions/reply.js";
|
|
9
|
+
import { insertablesOf } from "./insertables.js";
|
|
8
10
|
import { LEFT, PageScope } from "./page-scope.js";
|
|
9
11
|
/** A wait for time is 0.1–60 s */
|
|
10
12
|
const WAIT_MIN_MS = 100;
|
|
@@ -39,11 +41,13 @@ export class ToolbarController {
|
|
|
39
41
|
String(value),
|
|
40
42
|
]));
|
|
41
43
|
this.secrets = new Map(Object.entries(options.secrets));
|
|
44
|
+
this.inputs = new Map(Object.entries(options.inputs ?? {}));
|
|
42
45
|
this.actions = {
|
|
43
46
|
deps,
|
|
44
47
|
options,
|
|
45
48
|
variables: this.variables,
|
|
46
49
|
secrets: this.secrets,
|
|
50
|
+
inputs: this.inputs,
|
|
47
51
|
isPaused: () => this.paused,
|
|
48
52
|
pick: () => this.pick,
|
|
49
53
|
setPick: (pick) => {
|
|
@@ -64,11 +68,7 @@ export class ToolbarController {
|
|
|
64
68
|
steps: this.steps,
|
|
65
69
|
environment: this.options.environment,
|
|
66
70
|
canAddVariables: this.options.canAddVariables,
|
|
67
|
-
|
|
68
|
-
secrets: [...this.secrets].map(([name, value]) => ({
|
|
69
|
-
name,
|
|
70
|
-
set: value !== undefined,
|
|
71
|
-
})),
|
|
71
|
+
...insertablesOf(this.actions),
|
|
72
72
|
secretsIgnored: this.options.secretsIgnored,
|
|
73
73
|
resumeQuestion: this.question !== undefined,
|
|
74
74
|
nextScreenshot: `screenshot-${this.screenshots + 1}`,
|
|
@@ -146,6 +146,10 @@ export class ToolbarController {
|
|
|
146
146
|
return this.replying(message, () => insertAction(this.actions, message));
|
|
147
147
|
case "add-insertable":
|
|
148
148
|
return this.replying(message, () => addInsertableAction(this.actions, message));
|
|
149
|
+
case "save-output":
|
|
150
|
+
return this.replying(message, () => saveOutputAction(this.actions, message));
|
|
151
|
+
case "output-preview":
|
|
152
|
+
return this.reply(message.id, outputPreview(this.actions, message));
|
|
149
153
|
case "wait":
|
|
150
154
|
if (this.paused)
|
|
151
155
|
return;
|
|
@@ -278,7 +282,7 @@ export class ToolbarController {
|
|
|
278
282
|
}
|
|
279
283
|
/**
|
|
280
284
|
* Whether to process `message`; call it as the message arrives, before queueing it. An `assert`, `assert-page`,
|
|
281
|
-
* `insert` or `
|
|
285
|
+
* `insert`, `add-insertable` or `save-output` whose id is in flight or was already answered `{ ok: true }` is a double click and is
|
|
282
286
|
* dropped (its first copy answers). A retry after `{ ok: false }` is accepted.
|
|
283
287
|
*/
|
|
284
288
|
accept(message) {
|
|
@@ -319,7 +323,12 @@ export class ToolbarController {
|
|
|
319
323
|
return undefined;
|
|
320
324
|
const inspected = await this.deps.inspect(message.target, locator, message.candidates);
|
|
321
325
|
if (page === this.scope.current)
|
|
322
|
-
this.pick = {
|
|
326
|
+
this.pick = {
|
|
327
|
+
id: message.id,
|
|
328
|
+
locator,
|
|
329
|
+
counts: inspected.counts,
|
|
330
|
+
text: inspected.info.outputText,
|
|
331
|
+
};
|
|
323
332
|
return inspected;
|
|
324
333
|
});
|
|
325
334
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ToolbarState } from "./protocol.js";
|
|
2
|
+
/** What the Insert list shows of the variables, secrets (names only) and inputs the controller holds */
|
|
3
|
+
export declare function insertablesOf(held: {
|
|
4
|
+
variables: ReadonlyMap<string, string>;
|
|
5
|
+
secrets: ReadonlyMap<string, string | undefined>;
|
|
6
|
+
inputs: ReadonlyMap<string, string>;
|
|
7
|
+
}): Pick<ToolbarState, "variables" | "secrets" | "inputs">;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** What the Insert list shows of the variables, secrets (names only) and inputs the controller holds */
|
|
2
|
+
export function insertablesOf(held) {
|
|
3
|
+
const named = ([name, value]) => ({ name, value });
|
|
4
|
+
return {
|
|
5
|
+
variables: [...held.variables].map(named),
|
|
6
|
+
secrets: [...held.secrets].map(([name, value]) => ({
|
|
7
|
+
name,
|
|
8
|
+
set: value !== undefined,
|
|
9
|
+
})),
|
|
10
|
+
inputs: [...held.inputs].map(named),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -8,7 +8,7 @@ const MAX_COUNTS = 3;
|
|
|
8
8
|
/** The property Has CSS starts with */
|
|
9
9
|
const CSS_PROPERTY = "color";
|
|
10
10
|
/**
|
|
11
|
-
* Run in the toolbar's sandbox on the picked element: its text (cut at 4 KB), value (as the Alt+click rule reads it), first
|
|
11
|
+
* Run in the toolbar's sandbox on the picked element: its text (cut at 4 KB), its whole text as `outputs.save` reads it, value (as the Alt+click rule reads it), first
|
|
12
12
|
* 20 attributes, its role without a name and its `tag.class` selector. `roleOf` only knows the roles the
|
|
13
13
|
* candidates use, so the common list and table roles are added for Count. Also whether it can be checked or
|
|
14
14
|
* disabled, its class attribute and the computed value of `CSS_PROPERTY`.
|
|
@@ -27,6 +27,7 @@ const INSPECT_SOURCE = String.raw `(el, cssName) => {
|
|
|
27
27
|
const className = el.getAttribute("class");
|
|
28
28
|
return {
|
|
29
29
|
text: norm(String(el.textContent == null ? "" : el.textContent).slice(0, TEXT_CAP * 2)).slice(0, TEXT_CAP),
|
|
30
|
+
outputText: String(el.textContent == null ? "" : el.textContent).trim(),
|
|
30
31
|
value: hasValue && !secret ? el.value : null,
|
|
31
32
|
attributes: Array.from(el.attributes).slice(0, 20).map((a) => ({ name: a.name, value: a.value })),
|
|
32
33
|
role: roleOf(el) || MORE_ROLES[tag] || null,
|
|
@@ -94,6 +95,7 @@ export async function inspectElement(page, connector, context, target, locator,
|
|
|
94
95
|
ok: true,
|
|
95
96
|
label: describeCall(locator.chain[0]),
|
|
96
97
|
text: found.text,
|
|
98
|
+
outputText: found.outputText,
|
|
97
99
|
...(value !== undefined && { value }),
|
|
98
100
|
attributes: found.attributes,
|
|
99
101
|
first: {
|
|
@@ -25,6 +25,11 @@ export interface ToolbarState {
|
|
|
25
25
|
name: string;
|
|
26
26
|
set: boolean;
|
|
27
27
|
}[];
|
|
28
|
+
/** The recording test's declared inputs and their defaults */
|
|
29
|
+
inputs: {
|
|
30
|
+
name: string;
|
|
31
|
+
value: string;
|
|
32
|
+
}[];
|
|
28
33
|
/** Whether `.env.<environment>` is git-ignored */
|
|
29
34
|
secretsIgnored: boolean;
|
|
30
35
|
/** Ask "Add a step that opens this page?" */
|
|
@@ -93,17 +98,32 @@ export type ToolbarMessage = {
|
|
|
93
98
|
id: number;
|
|
94
99
|
target: unknown;
|
|
95
100
|
candidates: LocatorCall[];
|
|
96
|
-
kind: "env" | "secret";
|
|
101
|
+
kind: "env" | "secret" | "input";
|
|
97
102
|
name: string;
|
|
98
103
|
} | {
|
|
99
104
|
action: "add-insertable";
|
|
100
105
|
id: number;
|
|
101
106
|
target: unknown;
|
|
102
107
|
candidates: LocatorCall[];
|
|
103
|
-
kind: "env" | "secret";
|
|
108
|
+
kind: "env" | "secret" | "input";
|
|
104
109
|
name: string;
|
|
105
110
|
value: string;
|
|
106
111
|
}
|
|
112
|
+
/** Save as output for the element picked with the same id; `pattern` is a RegExp's source, no flags */
|
|
113
|
+
| {
|
|
114
|
+
action: "save-output";
|
|
115
|
+
id: number;
|
|
116
|
+
target: unknown;
|
|
117
|
+
candidates: LocatorCall[];
|
|
118
|
+
name: string;
|
|
119
|
+
pattern?: string;
|
|
120
|
+
}
|
|
121
|
+
/** The pattern of Save as output for an element that shows a secret, as typed: answered with an `OutputPreview` */
|
|
122
|
+
| {
|
|
123
|
+
action: "output-preview";
|
|
124
|
+
id: number;
|
|
125
|
+
pattern: string;
|
|
126
|
+
}
|
|
107
127
|
/** The Page panel opened: answered with a `PageInfo` */
|
|
108
128
|
| {
|
|
109
129
|
action: "page-info";
|
|
@@ -130,7 +150,12 @@ export type ToolbarMessage = {
|
|
|
130
150
|
export type PickInfo = {
|
|
131
151
|
ok: true;
|
|
132
152
|
label: string;
|
|
153
|
+
/** Whitespace collapsed and trimmed, cut at 4 KB: what the Assert panel shows and prefills */
|
|
133
154
|
text: string;
|
|
155
|
+
/** The whole text as `outputs.save` reads it (`textContent`, trimmed): what Save as output captures from */
|
|
156
|
+
outputText: string;
|
|
157
|
+
/** `outputText` held a secret, which is masked in it: Save as output asks the recorder for its preview */
|
|
158
|
+
showsSecret?: true;
|
|
134
159
|
value?: string;
|
|
135
160
|
attributes: {
|
|
136
161
|
name: string;
|
|
@@ -169,7 +194,22 @@ export type PageInfo = {
|
|
|
169
194
|
ok: false;
|
|
170
195
|
error: string;
|
|
171
196
|
};
|
|
172
|
-
/**
|
|
197
|
+
/**
|
|
198
|
+
* The answer to `output-preview`: what `outputs.save` would keep from the element's real text with `pattern`,
|
|
199
|
+
* every secret masked; `secret` when the value held one. `pattern` is the one asked about, so a late answer is dropped.
|
|
200
|
+
*/
|
|
201
|
+
export type OutputPreview = {
|
|
202
|
+
preview: true;
|
|
203
|
+
pattern: string;
|
|
204
|
+
} & ({
|
|
205
|
+
ok: true;
|
|
206
|
+
value: string;
|
|
207
|
+
secret: boolean;
|
|
208
|
+
} | {
|
|
209
|
+
ok: false;
|
|
210
|
+
error: string;
|
|
211
|
+
});
|
|
212
|
+
/** The answer to `insert`, `add-insertable`, `assert`, `assert-page` and `save-output` */
|
|
173
213
|
export type ActionReply = {
|
|
174
214
|
ok: true;
|
|
175
215
|
} | {
|
|
@@ -62,6 +62,7 @@ export const EVENTS_PAGE_JS = String.raw ` // ---- Events on the page (window c
|
|
|
62
62
|
}, true);
|
|
63
63
|
listen(window, "resize", () => render());
|
|
64
64
|
listen(window, "scroll", () => {
|
|
65
|
-
|
|
65
|
+
const picked = ui.panel === "assert" ? ui.assert : ui.panel === "output" ? ui.output : null;
|
|
66
|
+
if (picked && !outline.hidden) showOutline(picked.target);
|
|
66
67
|
}, true);
|
|
67
68
|
`;
|
|
@@ -11,6 +11,17 @@ export const FIELDS_JS = String.raw ` const press = (action, detail) => {
|
|
|
11
11
|
const value = t.value;
|
|
12
12
|
if (field === "expected" && ui.assert) ui.assert.expected = value;
|
|
13
13
|
else if (field === "pageExpected" && ui.page) ui.page.expected = value;
|
|
14
|
+
else if ((field === "outputName" || field === "outputPattern") && ui.output) {
|
|
15
|
+
const key = field === "outputName" ? "name" : "pattern";
|
|
16
|
+
if (ui.output[key] === value) return;
|
|
17
|
+
ui.output[key] = value;
|
|
18
|
+
if (key === "name") ui.output.nameError = null;
|
|
19
|
+
else {
|
|
20
|
+
ui.output.error = null;
|
|
21
|
+
if (ui.output.info && ui.output.info.ok && ui.output.info.showsSecret) askPreview(ui.output);
|
|
22
|
+
}
|
|
23
|
+
render();
|
|
24
|
+
}
|
|
14
25
|
else if (field === "attribute" && ui.assert) {
|
|
15
26
|
ui.assert.attribute = value;
|
|
16
27
|
ui.assert.expected = prefill(ui.assert, "toHaveAttribute");
|