@itmetsam/samurai-framework 0.21.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/browser/browser.js +19 -3
- package/dist/browser/chrome-launch.d.ts +2 -0
- package/dist/browser/chrome-launch.js +5 -0
- package/dist/config/mask.d.ts +6 -2
- package/dist/config/mask.js +4 -1
- package/dist/recorder/toolbar/actions/pick.d.ts +3 -0
- package/dist/recorder/toolbar/actions/pick.js +11 -1
- package/dist/recorder/toolbar/actions/save-output.d.ts +11 -5
- package/dist/recorder/toolbar/actions/save-output.js +50 -23
- package/dist/recorder/toolbar/controller.js +3 -1
- package/dist/recorder/toolbar/protocol.d.ts +23 -0
- package/dist/recorder/toolbar/source/fields.js +4 -1
- package/dist/recorder/toolbar/source/picking.js +1 -1
- package/dist/recorder/toolbar/source/recorder-side.js +8 -0
- package/dist/recorder/toolbar/source/views-panels.js +14 -2
- package/dist/recorder/toolbar/source.d.ts +2 -1
- package/dist/recorder/toolbar/source.js +2 -1
- package/dist/recorder/toolbar/types.d.ts +2 -2
- package/package.json +44 -4
package/README.md
CHANGED
|
@@ -149,6 +149,6 @@ The [documentation index](https://github.com/degrootsam/samurai-framework/wiki/H
|
|
|
149
149
|
| ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
150
150
|
| `bun run dev` | `samurai` from the sources (`tsx src/index.ts`) |
|
|
151
151
|
| `bun run test` | Unit tests of the framework (no browser needed) |
|
|
152
|
-
| `bun run test
|
|
153
|
-
| `bun run test
|
|
152
|
+
| `bun run test-browser` | Browser tests of the framework (Firefox; `SAMURAI_TEST_BROWSER=chrome` or `bun run test-browser-chrome` for Chrome) |
|
|
153
|
+
| `bun run test-cli` | Tests of the `samurai` command (build first) |
|
|
154
154
|
| `bun run build` | Typechecks and compiles to `dist/` |
|
package/dist/browser/browser.js
CHANGED
|
@@ -67,12 +67,28 @@ export class Browser {
|
|
|
67
67
|
commandTimeout,
|
|
68
68
|
signal,
|
|
69
69
|
});
|
|
70
|
+
// The launchers stop listening for the abort once the session is open. Until this returns, nobody else
|
|
71
|
+
// holds the browser to close it, so an abort still has to stop it here. Once: a second stop would cut
|
|
72
|
+
// Chrome's short (chromedriver killed while it still quits Chrome)
|
|
73
|
+
let abandoned = false;
|
|
74
|
+
const abandon = () => {
|
|
75
|
+
if (abandoned)
|
|
76
|
+
return;
|
|
77
|
+
abandoned = true;
|
|
78
|
+
abandonLaunch(proc, connector);
|
|
79
|
+
};
|
|
80
|
+
signal?.addEventListener("abort", abandon, { once: true });
|
|
70
81
|
try {
|
|
71
|
-
|
|
82
|
+
const launched = await Browser.attach(proc, connector, browserName);
|
|
83
|
+
signal?.throwIfAborted();
|
|
84
|
+
return launched;
|
|
72
85
|
}
|
|
73
86
|
catch (err) {
|
|
74
|
-
|
|
75
|
-
throw err;
|
|
87
|
+
abandon();
|
|
88
|
+
throw signal?.aborted ? signal.reason : err;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
signal?.removeEventListener("abort", abandon);
|
|
76
92
|
}
|
|
77
93
|
}
|
|
78
94
|
/** Sets up a browser whose BiDi session is open: downloads folder, the first page and what it tracks */
|
|
@@ -60,6 +60,8 @@ export declare class ChromeProcess extends EventEmitter implements BrowserProces
|
|
|
60
60
|
private deleteSession;
|
|
61
61
|
session: string | undefined;
|
|
62
62
|
private done;
|
|
63
|
+
/** The session is being deleted: chromedriver is killed once that is over, not before */
|
|
64
|
+
private quitting;
|
|
63
65
|
constructor(driver: Pick<ReturnType<typeof spawn>, "once" | "kill" | "exitCode" | "signalCode">, deleteSession?: (url: string) => Promise<unknown>);
|
|
64
66
|
get exitCode(): number | null;
|
|
65
67
|
get signalCode(): NodeJS.Signals | null;
|
|
@@ -139,6 +139,8 @@ export class ChromeProcess extends EventEmitter {
|
|
|
139
139
|
this.driver = driver;
|
|
140
140
|
this.deleteSession = deleteSession;
|
|
141
141
|
this.done = false;
|
|
142
|
+
/** The session is being deleted: chromedriver is killed once that is over, not before */
|
|
143
|
+
this.quitting = false;
|
|
142
144
|
driver.once("exit", () => this.ended());
|
|
143
145
|
}
|
|
144
146
|
get exitCode() {
|
|
@@ -159,7 +161,10 @@ export class ChromeProcess extends EventEmitter {
|
|
|
159
161
|
// Deleting the session makes chromedriver quit Chrome; killing chromedriver alone could leave it running
|
|
160
162
|
const session = this.session;
|
|
161
163
|
this.session = undefined;
|
|
164
|
+
if (this.quitting)
|
|
165
|
+
return true;
|
|
162
166
|
if (session) {
|
|
167
|
+
this.quitting = true;
|
|
163
168
|
this.deleteSession(session)
|
|
164
169
|
.catch(() => undefined)
|
|
165
170
|
.finally(() => this.driver.kill(signal));
|
package/dist/config/mask.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Logform } from "winston";
|
|
1
2
|
export declare const MASK = "\u2022\u2022\u2022\u2022";
|
|
2
3
|
/** Registers a secret value; every later maskText/maskDeep/log line hides it */
|
|
3
4
|
export declare function registerSecret(name: string, value: string): void;
|
|
@@ -7,5 +8,8 @@ export declare function isShortSecret(value: string): boolean;
|
|
|
7
8
|
export declare function maskText(text: string): string;
|
|
8
9
|
/** A copy of `value` with every string in it masked, following JSON.stringify semantics for objects */
|
|
9
10
|
export declare function maskDeep<T>(value: T): T;
|
|
10
|
-
/**
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Winston format that masks every string field of a log entry (message and metadata). Typed through winston's
|
|
13
|
+
* re-export: logform is winston's dependency, not ours, so an inferred type can't be named under an isolated install
|
|
14
|
+
*/
|
|
15
|
+
export declare const maskFormat: Logform.FormatWrap;
|
package/dist/config/mask.js
CHANGED
|
@@ -80,7 +80,10 @@ function walk(value, path) {
|
|
|
80
80
|
path.delete(value);
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Winston format that masks every string field of a log entry (message and metadata). Typed through winston's
|
|
85
|
+
* re-export: logform is winston's dependency, not ours, so an inferred type can't be named under an isolated install
|
|
86
|
+
*/
|
|
84
87
|
export const maskFormat = format((info) => {
|
|
85
88
|
if (patterns.length === 0)
|
|
86
89
|
return info;
|
|
@@ -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,11 +1,20 @@
|
|
|
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) {
|
|
@@ -24,7 +33,8 @@ export function withoutSecrets(info, secrets) {
|
|
|
24
33
|
return {
|
|
25
34
|
...rest,
|
|
26
35
|
text,
|
|
27
|
-
outputText:
|
|
36
|
+
outputText: maskSecrets(secrets)(info.outputText),
|
|
37
|
+
...(secret(info.outputText) && { showsSecret: true }),
|
|
28
38
|
attributes: info.attributes.filter((a) => !secret(a.value)),
|
|
29
39
|
className: secret(info.className ?? undefined) ? null : info.className,
|
|
30
40
|
css: secret(info.css.value) ? { ...info.css, value: "" } : info.css,
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import type { ActionReply } from "../protocol.js";
|
|
1
|
+
import type { ActionReply, OutputPreview } from "../protocol.js";
|
|
2
2
|
import type { ActionContext, Of } from "../types.js";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
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.
|
|
8
14
|
*/
|
|
9
15
|
export declare function saveOutputAction(ctx: ActionContext, message: Of<"save-output">): Promise<ActionReply>;
|
|
@@ -1,38 +1,65 @@
|
|
|
1
|
-
import { matchesText } from "../../../assert/text-match.js";
|
|
2
|
-
import { holdsSecret } from "./pick.js";
|
|
1
|
+
import { captured, matchesText } from "../../../assert/text-match.js";
|
|
2
|
+
import { holdsSecret, maskSecrets } from "./pick.js";
|
|
3
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
|
+
}
|
|
4
17
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
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.
|
|
9
46
|
*/
|
|
10
47
|
export async function saveOutputAction(ctx, message) {
|
|
11
48
|
if (ctx.isPaused())
|
|
12
49
|
return fail(PAUSED);
|
|
13
50
|
const pick = ctx.pick();
|
|
14
51
|
if (!pick || pick.id !== message.id)
|
|
15
|
-
return fail(
|
|
16
|
-
// The step would write the secret into the run's results
|
|
17
|
-
if (holdsSecret(ctx.secrets)(pick.text))
|
|
18
|
-
return fail("this element shows a secret; it can't be an output");
|
|
52
|
+
return fail(PICK_AGAIN);
|
|
19
53
|
const name = String(message.name ?? "").trim();
|
|
20
54
|
const problem = nameProblem(name);
|
|
21
55
|
if (problem)
|
|
22
56
|
return fail(problem, "name");
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
catch {
|
|
30
|
-
return fail("pattern is not a valid regular expression");
|
|
31
|
-
}
|
|
32
|
-
if (!matchesText(pick.text, pattern, "exact"))
|
|
33
|
-
return fail("pattern doesn't match the element's text");
|
|
34
|
-
}
|
|
35
|
-
else if (pick.text === "")
|
|
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 === "")
|
|
36
63
|
return fail("the element has no text");
|
|
37
64
|
ctx.setPick(undefined);
|
|
38
65
|
ctx.deps.observe({
|
|
@@ -4,7 +4,7 @@ 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 { saveOutputAction } from "./actions/save-output.js";
|
|
7
|
+
import { outputPreview, saveOutputAction } from "./actions/save-output.js";
|
|
8
8
|
import { fail, PAUSED, UNIDENTIFIABLE } from "./actions/reply.js";
|
|
9
9
|
import { insertablesOf } from "./insertables.js";
|
|
10
10
|
import { LEFT, PageScope } from "./page-scope.js";
|
|
@@ -148,6 +148,8 @@ export class ToolbarController {
|
|
|
148
148
|
return this.replying(message, () => addInsertableAction(this.actions, message));
|
|
149
149
|
case "save-output":
|
|
150
150
|
return this.replying(message, () => saveOutputAction(this.actions, message));
|
|
151
|
+
case "output-preview":
|
|
152
|
+
return this.reply(message.id, outputPreview(this.actions, message));
|
|
151
153
|
case "wait":
|
|
152
154
|
if (this.paused)
|
|
153
155
|
return;
|
|
@@ -118,6 +118,12 @@ export type ToolbarMessage = {
|
|
|
118
118
|
name: string;
|
|
119
119
|
pattern?: string;
|
|
120
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
|
+
}
|
|
121
127
|
/** The Page panel opened: answered with a `PageInfo` */
|
|
122
128
|
| {
|
|
123
129
|
action: "page-info";
|
|
@@ -148,6 +154,8 @@ export type PickInfo = {
|
|
|
148
154
|
text: string;
|
|
149
155
|
/** The whole text as `outputs.save` reads it (`textContent`, trimmed): what Save as output captures from */
|
|
150
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;
|
|
151
159
|
value?: string;
|
|
152
160
|
attributes: {
|
|
153
161
|
name: string;
|
|
@@ -186,6 +194,21 @@ export type PageInfo = {
|
|
|
186
194
|
ok: false;
|
|
187
195
|
error: string;
|
|
188
196
|
};
|
|
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
|
+
});
|
|
189
212
|
/** The answer to `insert`, `add-insertable`, `assert`, `assert-page` and `save-output` */
|
|
190
213
|
export type ActionReply = {
|
|
191
214
|
ok: true;
|
|
@@ -16,7 +16,10 @@ export const FIELDS_JS = String.raw ` const press = (action, detail) => {
|
|
|
16
16
|
if (ui.output[key] === value) return;
|
|
17
17
|
ui.output[key] = value;
|
|
18
18
|
if (key === "name") ui.output.nameError = null;
|
|
19
|
-
else
|
|
19
|
+
else {
|
|
20
|
+
ui.output.error = null;
|
|
21
|
+
if (ui.output.info && ui.output.info.ok && ui.output.info.showsSecret) askPreview(ui.output);
|
|
22
|
+
}
|
|
20
23
|
render();
|
|
21
24
|
}
|
|
22
25
|
else if (field === "attribute" && ui.assert) {
|
|
@@ -63,7 +63,7 @@ export const PICKING_JS = String.raw ` // ---- Panels and picking ----
|
|
|
63
63
|
const r = target.getBoundingClientRect();
|
|
64
64
|
const rect = { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
|
|
65
65
|
if (ui.pickFor === "output") {
|
|
66
|
-
ui.output = { id, target, candidates, info: null, name: "", pattern: "", nameError: null, error: null, rect };
|
|
66
|
+
ui.output = { id, target, candidates, info: null, name: "", pattern: "", preview: null, nameError: null, error: null, rect };
|
|
67
67
|
} else {
|
|
68
68
|
ui.assert = {
|
|
69
69
|
id, target, candidates, info: null, matcher: "toBeVisible", expected: "", attribute: null, countIndex: 0,
|
|
@@ -27,6 +27,13 @@ export const RECORDER_SIDE_JS = String.raw ` // ---- The recorder's side ----
|
|
|
27
27
|
render();
|
|
28
28
|
};
|
|
29
29
|
const reply = (id, result) => {
|
|
30
|
+
// A Save as output preview, for the pattern still typed (a late one is dropped); it doesn't end a pending action
|
|
31
|
+
if (result && result.preview) {
|
|
32
|
+
const o = ui.panel === "output" && ui.output;
|
|
33
|
+
if (!o || o.id !== id || o.pattern.trim() !== result.pattern) return;
|
|
34
|
+
o.preview = result;
|
|
35
|
+
return render();
|
|
36
|
+
}
|
|
30
37
|
if (!ui.pending || id !== ui.pending || !result) return;
|
|
31
38
|
ui.busy = false;
|
|
32
39
|
const ok = result.ok === true;
|
|
@@ -44,6 +51,7 @@ export const RECORDER_SIDE_JS = String.raw ` // ---- The recorder's side ----
|
|
|
44
51
|
} else if (ui.panel === "output" && ui.output && !ui.output.info) {
|
|
45
52
|
ui.output.info = result;
|
|
46
53
|
if (ok) ui.focus = "outputName";
|
|
54
|
+
if (ok && result.showsSecret) askPreview(ui.output);
|
|
47
55
|
} else if (ui.panel === "page" && ui.page && !ui.page.info) {
|
|
48
56
|
ui.page.info = result;
|
|
49
57
|
if (ok) {
|
|
@@ -82,17 +82,28 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
|
|
|
82
82
|
if (!match) return { error: "No match" };
|
|
83
83
|
return { value: match[1] !== undefined ? match[1] : match[0] };
|
|
84
84
|
};
|
|
85
|
+
// An element that shows a secret reaches the page with it masked: the recorder works out its preview from the real text
|
|
86
|
+
const askPreview = (o) => {
|
|
87
|
+
o.preview = null;
|
|
88
|
+
post({ action: "output-preview", id: o.id, pattern: o.pattern.trim() });
|
|
89
|
+
};
|
|
90
|
+
const outputCapture = (o) => {
|
|
91
|
+
if (!o.info.showsSecret) return captureOf(o.info.outputText, o.pattern);
|
|
92
|
+
if (!o.preview) return { value: "…" };
|
|
93
|
+
return o.preview.ok ? { value: o.preview.value, secret: o.preview.secret } : { error: o.preview.error };
|
|
94
|
+
};
|
|
85
95
|
const outputPanel = () => {
|
|
86
96
|
const o = ui.output;
|
|
87
97
|
const info = o.info;
|
|
88
98
|
const head = h("div", { class: "h" }, "Save as output", info && info.ok && h("code", { title: info.label }, info.label));
|
|
89
99
|
if (!info) return [head, h("div", { class: "msg" }, "…")];
|
|
90
100
|
if (!info.ok) return [head, h("div", { class: "err" }, info.error)];
|
|
91
|
-
const capture =
|
|
101
|
+
const capture = outputCapture(o);
|
|
92
102
|
return [
|
|
93
103
|
head,
|
|
104
|
+
info.showsSecret && h("div", { class: "warn out-secret" }, "This element shows a secret. The saved output may contain it; use a pattern that captures only the non-secret part."),
|
|
94
105
|
h("div", { class: "lbl" }, "Text"),
|
|
95
|
-
h("div", { class: "msg out-text" }, info.text),
|
|
106
|
+
h("div", { class: "msg out-text" }, info.showsSecret ? info.outputText : info.text),
|
|
96
107
|
h("div", { class: "lbl" }, "Name"),
|
|
97
108
|
h("div", { class: "f" }, h("input", { type: "text", class: "in code", "data-field": "outputName", value: o.name, "data-enter": "output-save", autocomplete: "off", spellcheck: "false" })),
|
|
98
109
|
o.nameError && h("div", { class: "field-error" }, o.nameError),
|
|
@@ -100,6 +111,7 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
|
|
|
100
111
|
h("div", { class: "f" }, h("input", { type: "text", class: "in code", "data-field": "outputPattern", value: o.pattern, placeholder: "Order #(\\d+)", "data-enter": "output-save", autocomplete: "off", spellcheck: "false" })),
|
|
101
112
|
h("div", { class: "lbl" }, "Saves"),
|
|
102
113
|
capture.error ? h("div", { class: "warn out-capture" }, capture.error) : h("code", { class: "out-capture" }, capture.value),
|
|
114
|
+
capture.secret && h("div", { class: "err out-leak" }, "The output will contain a secret: reports mask it, but whatever reads the output gets the real value."),
|
|
103
115
|
o.error && h("div", { class: "err" }, o.error),
|
|
104
116
|
h("div", { class: "foot" }, h("button", { class: "add", "data-action": "output-save", disabled: ui.busy || !o.name.trim() }, "Save")),
|
|
105
117
|
];
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* `globalThis[Symbol.for("samurai.toolbar")]` in the sandbox holds the hook:
|
|
10
10
|
* - `update(state)`: the recorder's state, after every change and every page load.
|
|
11
11
|
* - `reply(id, result)`: the answer to `pick` (a `PickInfo`), to `page-info` (a `PageInfo`) or to `assert`,
|
|
12
|
-
* `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`)
|
|
12
|
+
* `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`), or to `output-preview` (an
|
|
13
|
+
* `OutputPreview`, which carries `preview: true` and doesn't end a pending action).
|
|
13
14
|
* - `root`: the shadow root, for tests.
|
|
14
15
|
* - `press(action, detail?)`: runs the handler a click on that control runs (for tests). Actions:
|
|
15
16
|
* `pause`, `resume`, `stop`, `collapse`, `expand`, `resume-yes`, `resume-no` (rail);
|
|
@@ -24,7 +24,8 @@ import { ATTACH_JS } from "./source/attach.js";
|
|
|
24
24
|
* `globalThis[Symbol.for("samurai.toolbar")]` in the sandbox holds the hook:
|
|
25
25
|
* - `update(state)`: the recorder's state, after every change and every page load.
|
|
26
26
|
* - `reply(id, result)`: the answer to `pick` (a `PickInfo`), to `page-info` (a `PageInfo`) or to `assert`,
|
|
27
|
-
* `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`)
|
|
27
|
+
* `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`), or to `output-preview` (an
|
|
28
|
+
* `OutputPreview`, which carries `preview: true` and doesn't end a pending action).
|
|
28
29
|
* - `root`: the shadow root, for tests.
|
|
29
30
|
* - `press(action, detail?)`: runs the handler a click on that control runs (for tests). Actions:
|
|
30
31
|
* `pause`, `resume`, `stop`, `collapse`, `expand`, `resume-yes`, `resume-no` (rail);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ElementHandle } from "../../script/element-handle.js";
|
|
2
2
|
import type { LocatorCall, LocatorSpec } from "../../steps/model.js";
|
|
3
3
|
import type { Observation } from "../normalise.js";
|
|
4
|
-
import type { ActionReply, PageInfo, PickInfo, ToolbarMessage, ToolbarState } from "./protocol.js";
|
|
4
|
+
import type { ActionReply, OutputPreview, PageInfo, PickInfo, ToolbarMessage, ToolbarState } from "./protocol.js";
|
|
5
5
|
/** What `inspect` found for a picked element: the reply, and the locator behind each Count choice */
|
|
6
6
|
export interface Inspection {
|
|
7
7
|
info: PickInfo & {
|
|
@@ -14,7 +14,7 @@ export interface Inspection {
|
|
|
14
14
|
export interface ControllerDeps {
|
|
15
15
|
/** Sends the state to every loaded top-level document's toolbar */
|
|
16
16
|
push(state: ToolbarState): void;
|
|
17
|
-
reply(id: number, result: PickInfo | PageInfo | ActionReply): void;
|
|
17
|
+
reply(id: number, result: PickInfo | PageInfo | ActionReply | OutputPreview): void;
|
|
18
18
|
currentUrl(): Promise<string>;
|
|
19
19
|
/** The recorded page's URL and title */
|
|
20
20
|
pageInfo(): Promise<{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itmetsam/samurai-framework",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
|
|
5
5
|
"main": "dist/api.js",
|
|
6
6
|
"exports": {
|
|
@@ -57,11 +57,15 @@
|
|
|
57
57
|
"scripts": {
|
|
58
58
|
"dev": "tsx src/index.ts",
|
|
59
59
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/copy-types.mjs",
|
|
60
|
+
"build-watch": "node scripts/copy-types.mjs && tsc -w --preserveWatchOutput -p tsconfig.build.json",
|
|
60
61
|
"prepack": "npm run build",
|
|
62
|
+
"typecheck": "tsc --noEmit --noUnusedLocals",
|
|
61
63
|
"test": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-timeout=10000 \"src/**/*.test.ts\"",
|
|
62
|
-
"test
|
|
63
|
-
"test
|
|
64
|
-
"test
|
|
64
|
+
"test-browser": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 --test-timeout=60000 $(node scripts/browser-shards.mjs)",
|
|
65
|
+
"test-browser-chrome": "SAMURAI_TEST_BROWSER=chrome bun run test-browser",
|
|
66
|
+
"test-cli": "tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 \"src/**/*.cli-test.ts\"",
|
|
67
|
+
"knip": "knip --no-config-hints",
|
|
68
|
+
"madge": "madge --circular src"
|
|
65
69
|
},
|
|
66
70
|
"keywords": [
|
|
67
71
|
"testing",
|
|
@@ -77,6 +81,8 @@
|
|
|
77
81
|
"devDependencies": {
|
|
78
82
|
"@types/node": "^25.6.0",
|
|
79
83
|
"esbuild": "^0.28.2",
|
|
84
|
+
"knip": "^6.42.1",
|
|
85
|
+
"madge": "^8.0.0",
|
|
80
86
|
"tsx": "^4.23.15"
|
|
81
87
|
},
|
|
82
88
|
"dependencies": {
|
|
@@ -113,5 +119,39 @@
|
|
|
113
119
|
},
|
|
114
120
|
"engines": {
|
|
115
121
|
"node": ">=24"
|
|
122
|
+
},
|
|
123
|
+
"nx": {
|
|
124
|
+
"name": "framework",
|
|
125
|
+
"targets": {
|
|
126
|
+
"test": {
|
|
127
|
+
"dependsOn": [
|
|
128
|
+
"build"
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
"test-cli": {
|
|
132
|
+
"dependsOn": [
|
|
133
|
+
"build"
|
|
134
|
+
],
|
|
135
|
+
"cache": true,
|
|
136
|
+
"parallelism": false
|
|
137
|
+
},
|
|
138
|
+
"test-browser": {
|
|
139
|
+
"cache": true,
|
|
140
|
+
"parallelism": false,
|
|
141
|
+
"inputs": [
|
|
142
|
+
"default",
|
|
143
|
+
{
|
|
144
|
+
"env": "SAMURAI_TEST_BROWSER"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"env": "SAMURAI_TEST_SHARD"
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
},
|
|
151
|
+
"build-watch": {
|
|
152
|
+
"continuous": true,
|
|
153
|
+
"cache": false
|
|
154
|
+
}
|
|
155
|
+
}
|
|
116
156
|
}
|
|
117
157
|
}
|