@itmetsam/samurai-framework 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -12
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/browser/browser-finder.js +2 -2
- package/dist/browser/browser.d.ts +11 -15
- package/dist/browser/browser.js +79 -58
- package/dist/browser/chrome-launch.d.ts +57 -0
- package/dist/browser/chrome-launch.js +127 -0
- package/dist/browser/download-folders.d.ts +27 -0
- package/dist/browser/download-folders.js +97 -0
- package/dist/browser/file-chooser.d.ts +4 -3
- package/dist/browser/file-chooser.js +9 -8
- package/dist/browser/generated-files.d.ts +30 -0
- package/dist/browser/generated-files.js +116 -0
- package/dist/browser/page-logs.d.ts +2 -0
- package/dist/browser/page-logs.js +25 -5
- package/dist/browser/page.d.ts +37 -0
- package/dist/browser/page.js +129 -2
- package/dist/browser/screenshot.js +16 -2
- package/dist/browser/session-capabilities.d.ts +12 -0
- package/dist/browser/session-capabilities.js +8 -0
- package/dist/browsers/index.d.ts +2 -2
- package/dist/browsers/index.js +2 -2
- package/dist/browsers/overrides.d.ts +2 -0
- package/dist/browsers/overrides.js +2 -0
- package/dist/browsers/pins.d.ts +1 -1
- package/dist/browsers/pins.js +2 -2
- package/dist/browsers/resolve.d.ts +2 -0
- package/dist/browsers/resolve.js +14 -2
- package/dist/browsers/store.d.ts +2 -0
- package/dist/browsers/store.js +8 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +20 -5
- package/dist/locator/locator.d.ts +17 -5
- package/dist/locator/locator.js +77 -10
- package/dist/network/data-collector.d.ts +1 -0
- package/dist/network/data-collector.js +23 -5
- package/dist/network/network-tracker.d.ts +8 -1
- package/dist/network/network-tracker.js +42 -3
- package/dist/network/router.d.ts +5 -0
- package/dist/network/router.js +33 -11
- package/dist/project/files-store.d.ts +28 -0
- package/dist/project/files-store.js +106 -0
- package/dist/project/index.d.ts +1 -0
- package/dist/project/index.js +1 -0
- package/dist/recorder/apply.js +8 -6
- package/dist/recorder/files-capture.d.ts +26 -0
- package/dist/recorder/files-capture.js +92 -0
- package/dist/recorder/files-reader.d.ts +7 -0
- package/dist/recorder/files-reader.js +25 -0
- package/dist/recorder/index.d.ts +1 -1
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/normalise.d.ts +26 -2
- package/dist/recorder/normalise.js +48 -1
- package/dist/recorder/recorder.d.ts +38 -0
- package/dist/recorder/recorder.js +160 -1
- package/dist/recorder/session.d.ts +14 -1
- package/dist/recorder/session.js +36 -9
- package/dist/recorder/toolbar/controller.d.ts +15 -5
- package/dist/recorder/toolbar/controller.js +61 -7
- package/dist/recorder/toolbar/protocol.d.ts +10 -0
- package/dist/recorder/toolbar/source.d.ts +1 -1
- package/dist/recorder/toolbar/source.js +35 -2
- package/dist/recorder/toolbar/styles.d.ts +1 -1
- package/dist/recorder/toolbar/styles.js +31 -0
- package/dist/runner/project.js +2 -0
- package/dist/script/preload.js +65 -0
- package/dist/steps/edit.js +38 -3
- package/dist/steps/emit.d.ts +3 -1
- package/dist/steps/emit.js +19 -0
- package/dist/steps/files.d.ts +8 -0
- package/dist/steps/files.js +33 -0
- package/dist/steps/index.d.ts +3 -2
- package/dist/steps/index.js +2 -1
- package/dist/steps/model.d.ts +28 -0
- package/dist/steps/parse.js +79 -0
- package/dist/transport/bidi-connection.d.ts +6 -0
- package/dist/transport/bidi-connection.js +13 -0
- package/dist/types/browser.d.ts +1 -1
- package/dist/types/config.d.ts +7 -2
- package/package.json +3 -2
- package/dist/browser/file-paths.d.ts +0 -4
- package/dist/browser/file-paths.js +0 -18
|
@@ -36,6 +36,13 @@ export interface ToolbarState {
|
|
|
36
36
|
id: number;
|
|
37
37
|
text: string;
|
|
38
38
|
};
|
|
39
|
+
/** The name of the file being saved to the project, while a transfer runs */
|
|
40
|
+
saving?: string;
|
|
41
|
+
/** Shown until the tester dismisses each (a file that wasn't saved) */
|
|
42
|
+
warnings: {
|
|
43
|
+
id: number;
|
|
44
|
+
text: string;
|
|
45
|
+
}[];
|
|
39
46
|
}
|
|
40
47
|
/** Element-carrying messages send the element itself (it arrives as a handle) and its candidates */
|
|
41
48
|
export type ToolbarMessage = {
|
|
@@ -61,6 +68,9 @@ export type ToolbarMessage = {
|
|
|
61
68
|
} | {
|
|
62
69
|
action: "collapse";
|
|
63
70
|
collapsed: boolean;
|
|
71
|
+
} | {
|
|
72
|
+
action: "dismiss";
|
|
73
|
+
id: number;
|
|
64
74
|
} | {
|
|
65
75
|
action: "pick";
|
|
66
76
|
id: number;
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* `matcher { matcher }`, `not`, `assert-add` (Assert panel);
|
|
19
19
|
* `insert-item { kind, name }`, `insert-first`, `insert-new { kind }` (Insert list);
|
|
20
20
|
* `new-kind { kind }`, `new-save`, `new-cancel` (New variable or secret);
|
|
21
|
-
* `wait-add`, `screenshot-add`; `escape` (the Esc key).
|
|
21
|
+
* `wait-add`, `screenshot-add`; `escape` (the Esc key); `dismiss { id }` (a warning's Dismiss button).
|
|
22
22
|
* Form inputs carry `data-field` (`expected`, `attribute`, `countIndex`, `filter`, `name`, `value`,
|
|
23
23
|
* `waitKind`, `seconds`, `shotName`, `fullPage`) and update on `input`/`change`.
|
|
24
24
|
* - `rebind(send)`: used by a second run in the same document (a new recording), which keeps the host and
|
|
@@ -21,7 +21,7 @@ import { TOOLBAR_CSS } from "./styles.js";
|
|
|
21
21
|
* `matcher { matcher }`, `not`, `assert-add` (Assert panel);
|
|
22
22
|
* `insert-item { kind, name }`, `insert-first`, `insert-new { kind }` (Insert list);
|
|
23
23
|
* `new-kind { kind }`, `new-save`, `new-cancel` (New variable or secret);
|
|
24
|
-
* `wait-add`, `screenshot-add`; `escape` (the Esc key).
|
|
24
|
+
* `wait-add`, `screenshot-add`; `escape` (the Esc key); `dismiss { id }` (a warning's Dismiss button).
|
|
25
25
|
* Form inputs carry `data-field` (`expected`, `attribute`, `countIndex`, `filter`, `name`, `value`,
|
|
26
26
|
* `waitKind`, `seconds`, `shotName`, `fullPage`) and update on `input`/`change`.
|
|
27
27
|
* - `rebind(send)`: used by a second run in the same document (a new recording), which keeps the host and
|
|
@@ -46,6 +46,7 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
46
46
|
const DEFAULTS = {
|
|
47
47
|
status: "recording", collapsed: false, position: null, steps: 0, environment: "", canAddVariables: false,
|
|
48
48
|
variables: [], secrets: [], secretsIgnored: true, resumeQuestion: false, nextScreenshot: "screenshot-1",
|
|
49
|
+
warnings: [],
|
|
49
50
|
};
|
|
50
51
|
const MATCHERS = [
|
|
51
52
|
["toBeVisible", "Visible"], ["toHaveText", "Has text"], ["toContainText", "Contains text"],
|
|
@@ -109,6 +110,7 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
109
110
|
const container = h("div", { class: "ui" });
|
|
110
111
|
const outline = h("div", { class: "outline", hidden: true });
|
|
111
112
|
const toast = h("div", { class: "toast ui", hidden: true });
|
|
113
|
+
const notices = h("div", { class: "notices ui" });
|
|
112
114
|
let adopted = false;
|
|
113
115
|
try {
|
|
114
116
|
const sheet = new CSSStyleSheet();
|
|
@@ -119,7 +121,7 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
119
121
|
adopted = false;
|
|
120
122
|
}
|
|
121
123
|
// Without constructed sheets: a <style> element, which a strict CSP blocks
|
|
122
|
-
root.append(...(adopted ? [] : [h("style", null, CSS)]), frame, outline, container, toast);
|
|
124
|
+
root.append(...(adopted ? [] : [h("style", null, CSS)]), frame, outline, container, toast, notices);
|
|
123
125
|
|
|
124
126
|
const viewport = () => ({ w: frame.clientWidth || innerWidth, h: frame.clientHeight || innerHeight });
|
|
125
127
|
|
|
@@ -366,6 +368,34 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
366
368
|
];
|
|
367
369
|
const PANELS = { assert: assertPanel, insert: insertPanel, new: newPanel, wait: waitPanel, screenshot: screenshotPanel };
|
|
368
370
|
|
|
371
|
+
// Notice nodes live as long as their warning: rebuilding them would make screen readers announce each alert again
|
|
372
|
+
const noticeNodes = new Map();
|
|
373
|
+
let savingNode = null;
|
|
374
|
+
let savingText = null;
|
|
375
|
+
const renderNotices = () => {
|
|
376
|
+
const ids = new Set(state.warnings.map((w) => w.id));
|
|
377
|
+
for (const [id, node] of noticeNodes) {
|
|
378
|
+
if (ids.has(id)) continue;
|
|
379
|
+
node.remove();
|
|
380
|
+
noticeNodes.delete(id);
|
|
381
|
+
}
|
|
382
|
+
for (const w of state.warnings) {
|
|
383
|
+
if (noticeNodes.has(w.id)) continue;
|
|
384
|
+
const node = h("div", { class: "notice warning", "data-warning": String(w.id), role: "alert" },
|
|
385
|
+
h("span", null, w.text),
|
|
386
|
+
h("button", { class: "dismiss", "data-action": "dismiss", "data-id": String(w.id) }, "Dismiss"));
|
|
387
|
+
noticeNodes.set(w.id, node);
|
|
388
|
+
// Before the saving line, which stays last
|
|
389
|
+
notices.insertBefore(node, savingNode);
|
|
390
|
+
}
|
|
391
|
+
const saving = state.saving || null;
|
|
392
|
+
if (saving !== savingText) {
|
|
393
|
+
savingText = saving;
|
|
394
|
+
if (savingNode) savingNode.remove();
|
|
395
|
+
savingNode = saving ? h("div", { class: "notice saving" }, "Saving " + saving + "…") : null;
|
|
396
|
+
if (savingNode) notices.append(savingNode);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
369
399
|
const render = () => {
|
|
370
400
|
const active = root.activeElement;
|
|
371
401
|
const kept = active && active.type !== "radio" ? active.getAttribute("data-field") : null;
|
|
@@ -373,6 +403,7 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
373
403
|
try { selection = kept ? [active.selectionStart, active.selectionEnd] : null; } catch (err) { selection = null; }
|
|
374
404
|
const panel = ui.panel && !state.collapsed ? h("div", { class: ui.panel === "new" || ui.panel === "insert" ? "panel wide" : "panel" }, PANELS[ui.panel]()) : null;
|
|
375
405
|
container.replaceChildren(state.collapsed ? chipView() : railView(), ...(panel ? [panel] : []));
|
|
406
|
+
renderNotices();
|
|
376
407
|
placeAll();
|
|
377
408
|
const focus = ui.focus || kept;
|
|
378
409
|
ui.focus = null;
|
|
@@ -457,6 +488,7 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
457
488
|
|
|
458
489
|
// Every action is what a click on its control does; press() runs the same functions
|
|
459
490
|
const handlers = {
|
|
491
|
+
dismiss: (d) => post({ action: "dismiss", id: Number(d.id) }),
|
|
460
492
|
pause: () => { if (!paused()) post({ action: "pause" }); },
|
|
461
493
|
resume: () => { if (paused()) post({ action: "resume" }); },
|
|
462
494
|
stop: () => {
|
|
@@ -670,6 +702,7 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
670
702
|
kind: control.getAttribute("data-kind"),
|
|
671
703
|
name: control.getAttribute("data-name"),
|
|
672
704
|
matcher: control.getAttribute("data-matcher"),
|
|
705
|
+
id: control.getAttribute("data-id"),
|
|
673
706
|
});
|
|
674
707
|
});
|
|
675
708
|
root.addEventListener("pointerdown", (event) => {
|
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
* outline and the toast, as in the approved mockups. Everything inside is `position: fixed` against the
|
|
4
4
|
* viewport; the host itself takes no space.
|
|
5
5
|
*/
|
|
6
|
-
export declare const TOOLBAR_CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n.ui { font: 12px/1.3 system-ui, -apple-system, \"Segoe UI\", sans-serif; color: #222; color-scheme: light; }\nbutton, input, select { font: inherit; color: inherit; margin: 0; }\nbutton { appearance: none; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }\nbutton:disabled { cursor: default; }\ncode { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; }\n\n.rail, .chip { position: fixed; z-index: 3; box-shadow: 0 6px 20px rgba(0,0,0,.28); background: #17181c; color: #e9eaee; user-select: none; }\n.rail { display: flex; flex-direction: column; gap: 2px; border-radius: 10px; padding: 5px; width: 112px; }\n.rail.wide { width: 150px; }\n.top { display: flex; align-items: center; justify-content: space-between; color: #6b6e78; padding: 0 2px 2px; }\n[data-grip] { cursor: grab; flex: 1; touch-action: none; }\n[data-grip]:active { cursor: grabbing; }\n.chev { color: #6b6e78; padding: 0 3px; border-radius: 4px; }\n.chev:hover { color: #e9eaee; }\n.st { display: flex; align-items: center; gap: 6px; padding: 4px 6px; font-weight: 600; }\n.dot { width: 8px; height: 8px; border-radius: 50%; background: #ff4d4f; box-shadow: 0 0 0 3px rgba(255,77,79,.25); flex: none; }\n.dot.pause { background: #f5b301; box-shadow: 0 0 0 3px rgba(245,179,1,.25); }\n.b { padding: 5px 7px; border-radius: 6px; display: flex; align-items: center; gap: 6px; width: 100%; }\n.b:hover:not(:disabled) { background: #2a2c33; }\n.b.on, .b.on:hover:not(:disabled) { background: #2f6fed; color: #fff; }\n.b:disabled { opacity: .38; }\n.sep { height: 1px; background: #34363d; margin: 2px 3px; }\n.stop { color: #ff8a8c; }\n.ico { width: 14px; text-align: center; flex: none; }\n.note { font-size: 10.5px; color: #c9cbd2; padding: 4px 6px 2px; line-height: 1.35; }\n.ask { display: flex; gap: 4px; padding: 2px 6px 4px; }\n.ask button { flex: 1; text-align: center; border-radius: 5px; padding: 3px 0; background: #2a2c33; }\n.ask button.yes { background: #2f6fed; color: #fff; font-weight: 600; }\n\n.chip { display: flex; align-items: center; gap: 7px; border-radius: 999px; padding: 6px 10px 6px 8px; font-weight: 600; }\n.chip [data-grip] { color: #6b6e78; flex: none; }\n.chip .chev { color: #9a9ca5; }\n\n.panel { position: fixed; z-index: 2; width: 230px; max-height: calc(100vh - 16px); overflow: auto; background: #fff; border: 1px solid #d9dce1; border-radius: 10px; box-shadow: 0 10px 28px rgba(0,0,0,.18); color: #222; }\n.panel.wide { width: 236px; }\n.h { padding: 8px 10px; border-bottom: 1px solid #eceef1; font-weight: 600; display: flex; justify-content: space-between; gap: 8px; }\n.h.stack { display: block; }\n.h code { font-size: 11px; color: #555; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.h small { display: block; font-weight: 400; color: #777; margin-top: 2px; }\n.msg { padding: 10px; color: #555; }\n.err { padding: 6px 10px; color: #c22b2e; }\n.opts { padding: 6px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }\n.o { border: 1px solid #e3e5e8; border-radius: 6px; padding: 5px 6px; }\n.o:hover:not(:disabled) { border-color: #b9c6e4; }\n.o.sel { border-color: #2f6fed; background: #eef3fe; color: #1d4fc4; font-weight: 600; }\n.o:disabled { opacity: .45; }\n.val { margin: 2px 8px 8px; display: flex; flex-direction: column; gap: 4px; }\n.in { width: 100%; border: 1px solid #cfd3d8; border-radius: 4px; padding: 5px 6px; background: #fff; outline: none; }\n.in:focus { border-color: #2f6fed; box-shadow: 0 0 0 2px rgba(47,111,237,.15); }\n.in.code { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; }\n.row { display: flex; justify-content: space-between; align-items: center; padding: 0 8px 8px; }\n.neg { color: #555; display: flex; align-items: center; gap: 4px; }\n.add { background: #2f6fed; color: #fff; border-radius: 6px; padding: 5px 12px; font-weight: 600; text-align: center; }\n.add:disabled { opacity: .5; }\n.cancel { padding: 5px 10px; color: #555; }\n.foot { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 10px; border-top: 1px solid #eceef1; }\n.foot.plain { border-top: 0; padding-top: 10px; }\n\n.search { display: block; margin: 6px 8px; width: calc(100% - 16px); }\n.grp { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: #888; padding: 6px 10px 2px; }\n.it { display: flex; justify-content: space-between; gap: 8px; padding: 5px 10px; width: 100%; }\n.it:hover, .it.sel { background: #eef3fe; }\n.it .v { color: #777; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.warn { color: #b26a00; }\n.empty { padding: 4px 10px; color: #777; }\n.act { padding: 6px 10px; color: #1d4fc4; font-weight: 600; width: 100%; }\n.act:hover { background: #eef3fe; }\n\n.seg { display: flex; margin: 8px 10px 0; border: 1px solid #cfd3d8; border-radius: 6px; overflow: hidden; }\n.seg button { flex: 1; text-align: center; padding: 4px; }\n.seg .sel { background: #eef3fe; color: #1d4fc4; font-weight: 600; }\n.lbl { font-size: 11px; color: #666; padding: 6px 10px 2px; }\n.f { padding: 0 10px; }\n.field-error { color: #c22b2e; font-size: 11px; padding: 3px 10px 0; }\n.hint { padding: 6px 10px 0; color: #666; font-size: 11px; line-height: 1.35; }\n.hint code { font-size: 10.5px; }\n\n.radio { padding: 6px 10px; display: flex; gap: 7px; align-items: center; }\n.radio input[type=radio] { accent-color: #2f6fed; margin: 0; }\n.sub { color: #777; font-size: 11px; padding: 0 10px 2px 30px; margin-top: -4px; }\n.num { width: 52px; border: 1px solid #cfd3d8; border-radius: 4px; padding: 3px 5px; }\n\n.outline { position: fixed; z-index: 1; pointer-events: none; outline: 2px solid #2f6fed; outline-offset: 2px; border-radius: 4px; background: rgba(47,111,237,.06); }\n.toast { position: fixed; z-index: 4; left: 50%; bottom: 14px; transform: translateX(-50%); background: #17181c; color: #e9eaee; border-radius: 8px; padding: 6px 12px; box-shadow: 0 6px 20px rgba(0,0,0,.28); pointer-events: none; transition: opacity .3s; }\n.toast.gone { opacity: 0; }\n.frame { position: fixed; inset: 0; pointer-events: none; visibility: hidden; }\n";
|
|
6
|
+
export declare const TOOLBAR_CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n.ui { font: 12px/1.3 system-ui, -apple-system, \"Segoe UI\", sans-serif; color: #222; color-scheme: light; }\nbutton, input, select { font: inherit; color: inherit; margin: 0; }\nbutton { appearance: none; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }\nbutton:disabled { cursor: default; }\ncode { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; }\n\n.rail, .chip { position: fixed; z-index: 3; box-shadow: 0 6px 20px rgba(0,0,0,.28); background: #17181c; color: #e9eaee; user-select: none; }\n.rail { display: flex; flex-direction: column; gap: 2px; border-radius: 10px; padding: 5px; width: 112px; }\n.rail.wide { width: 150px; }\n.top { display: flex; align-items: center; justify-content: space-between; color: #6b6e78; padding: 0 2px 2px; }\n[data-grip] { cursor: grab; flex: 1; touch-action: none; }\n[data-grip]:active { cursor: grabbing; }\n.chev { color: #6b6e78; padding: 0 3px; border-radius: 4px; }\n.chev:hover { color: #e9eaee; }\n.st { display: flex; align-items: center; gap: 6px; padding: 4px 6px; font-weight: 600; }\n.dot { width: 8px; height: 8px; border-radius: 50%; background: #ff4d4f; box-shadow: 0 0 0 3px rgba(255,77,79,.25); flex: none; }\n.dot.pause { background: #f5b301; box-shadow: 0 0 0 3px rgba(245,179,1,.25); }\n.b { padding: 5px 7px; border-radius: 6px; display: flex; align-items: center; gap: 6px; width: 100%; }\n.b:hover:not(:disabled) { background: #2a2c33; }\n.b.on, .b.on:hover:not(:disabled) { background: #2f6fed; color: #fff; }\n.b:disabled { opacity: .38; }\n.sep { height: 1px; background: #34363d; margin: 2px 3px; }\n.stop { color: #ff8a8c; }\n.ico { width: 14px; text-align: center; flex: none; }\n.note { font-size: 10.5px; color: #c9cbd2; padding: 4px 6px 2px; line-height: 1.35; }\n.ask { display: flex; gap: 4px; padding: 2px 6px 4px; }\n.ask button { flex: 1; text-align: center; border-radius: 5px; padding: 3px 0; background: #2a2c33; }\n.ask button.yes { background: #2f6fed; color: #fff; font-weight: 600; }\n\n.chip { display: flex; align-items: center; gap: 7px; border-radius: 999px; padding: 6px 10px 6px 8px; font-weight: 600; }\n.chip [data-grip] { color: #6b6e78; flex: none; }\n.chip .chev { color: #9a9ca5; }\n\n.panel { position: fixed; z-index: 2; width: 230px; max-height: calc(100vh - 16px); overflow: auto; background: #fff; border: 1px solid #d9dce1; border-radius: 10px; box-shadow: 0 10px 28px rgba(0,0,0,.18); color: #222; }\n.panel.wide { width: 236px; }\n.h { padding: 8px 10px; border-bottom: 1px solid #eceef1; font-weight: 600; display: flex; justify-content: space-between; gap: 8px; }\n.h.stack { display: block; }\n.h code { font-size: 11px; color: #555; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.h small { display: block; font-weight: 400; color: #777; margin-top: 2px; }\n.msg { padding: 10px; color: #555; }\n.err { padding: 6px 10px; color: #c22b2e; }\n.opts { padding: 6px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }\n.o { border: 1px solid #e3e5e8; border-radius: 6px; padding: 5px 6px; }\n.o:hover:not(:disabled) { border-color: #b9c6e4; }\n.o.sel { border-color: #2f6fed; background: #eef3fe; color: #1d4fc4; font-weight: 600; }\n.o:disabled { opacity: .45; }\n.val { margin: 2px 8px 8px; display: flex; flex-direction: column; gap: 4px; }\n.in { width: 100%; border: 1px solid #cfd3d8; border-radius: 4px; padding: 5px 6px; background: #fff; outline: none; }\n.in:focus { border-color: #2f6fed; box-shadow: 0 0 0 2px rgba(47,111,237,.15); }\n.in.code { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; }\n.row { display: flex; justify-content: space-between; align-items: center; padding: 0 8px 8px; }\n.neg { color: #555; display: flex; align-items: center; gap: 4px; }\n.add { background: #2f6fed; color: #fff; border-radius: 6px; padding: 5px 12px; font-weight: 600; text-align: center; }\n.add:disabled { opacity: .5; }\n.cancel { padding: 5px 10px; color: #555; }\n.foot { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 10px; border-top: 1px solid #eceef1; }\n.foot.plain { border-top: 0; padding-top: 10px; }\n\n.search { display: block; margin: 6px 8px; width: calc(100% - 16px); }\n.grp { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: #888; padding: 6px 10px 2px; }\n.it { display: flex; justify-content: space-between; gap: 8px; padding: 5px 10px; width: 100%; }\n.it:hover, .it.sel { background: #eef3fe; }\n.it .v { color: #777; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.warn { color: #b26a00; }\n.empty { padding: 4px 10px; color: #777; }\n.act { padding: 6px 10px; color: #1d4fc4; font-weight: 600; width: 100%; }\n.act:hover { background: #eef3fe; }\n\n.seg { display: flex; margin: 8px 10px 0; border: 1px solid #cfd3d8; border-radius: 6px; overflow: hidden; }\n.seg button { flex: 1; text-align: center; padding: 4px; }\n.seg .sel { background: #eef3fe; color: #1d4fc4; font-weight: 600; }\n.lbl { font-size: 11px; color: #666; padding: 6px 10px 2px; }\n.f { padding: 0 10px; }\n.field-error { color: #c22b2e; font-size: 11px; padding: 3px 10px 0; }\n.hint { padding: 6px 10px 0; color: #666; font-size: 11px; line-height: 1.35; }\n.hint code { font-size: 10.5px; }\n\n.radio { padding: 6px 10px; display: flex; gap: 7px; align-items: center; }\n.radio input[type=radio] { accent-color: #2f6fed; margin: 0; }\n.sub { color: #777; font-size: 11px; padding: 0 10px 2px 30px; margin-top: -4px; }\n.num { width: 52px; border: 1px solid #cfd3d8; border-radius: 4px; padding: 3px 5px; }\n\n.outline { position: fixed; z-index: 1; pointer-events: none; outline: 2px solid #2f6fed; outline-offset: 2px; border-radius: 4px; background: rgba(47,111,237,.06); }\n.toast { position: fixed; z-index: 4; left: 50%; bottom: 14px; transform: translateX(-50%); background: #17181c; color: #e9eaee; border-radius: 8px; padding: 6px 12px; box-shadow: 0 6px 20px rgba(0,0,0,.28); pointer-events: none; transition: opacity .3s; }\n.toast.gone { opacity: 0; }\n.notices {\n position: fixed;\n z-index: 4;\n left: 50%;\n bottom: 52px;\n transform: translateX(-50%);\n display: flex;\n flex-direction: column;\n gap: 6px;\n max-width: min(520px, calc(100vw - 32px));\n}\n.notice {\n display: flex;\n gap: 10px;\n align-items: center;\n background: #17181c;\n color: #e9eaee;\n border-radius: 8px;\n padding: 8px 12px;\n box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);\n}\n.notice.warning {\n background: #3b2a06;\n color: #ffe7b0;\n border: 1px solid #8a6514;\n}\n.notice .dismiss {\n margin-left: auto;\n text-decoration: underline;\n white-space: nowrap;\n}\n.frame { position: fixed; inset: 0; pointer-events: none; visibility: hidden; }\n";
|
|
@@ -91,5 +91,36 @@ code { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
|
91
91
|
.outline { position: fixed; z-index: 1; pointer-events: none; outline: 2px solid #2f6fed; outline-offset: 2px; border-radius: 4px; background: rgba(47,111,237,.06); }
|
|
92
92
|
.toast { position: fixed; z-index: 4; left: 50%; bottom: 14px; transform: translateX(-50%); background: #17181c; color: #e9eaee; border-radius: 8px; padding: 6px 12px; box-shadow: 0 6px 20px rgba(0,0,0,.28); pointer-events: none; transition: opacity .3s; }
|
|
93
93
|
.toast.gone { opacity: 0; }
|
|
94
|
+
.notices {
|
|
95
|
+
position: fixed;
|
|
96
|
+
z-index: 4;
|
|
97
|
+
left: 50%;
|
|
98
|
+
bottom: 52px;
|
|
99
|
+
transform: translateX(-50%);
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
gap: 6px;
|
|
103
|
+
max-width: min(520px, calc(100vw - 32px));
|
|
104
|
+
}
|
|
105
|
+
.notice {
|
|
106
|
+
display: flex;
|
|
107
|
+
gap: 10px;
|
|
108
|
+
align-items: center;
|
|
109
|
+
background: #17181c;
|
|
110
|
+
color: #e9eaee;
|
|
111
|
+
border-radius: 8px;
|
|
112
|
+
padding: 8px 12px;
|
|
113
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
|
|
114
|
+
}
|
|
115
|
+
.notice.warning {
|
|
116
|
+
background: #3b2a06;
|
|
117
|
+
color: #ffe7b0;
|
|
118
|
+
border: 1px solid #8a6514;
|
|
119
|
+
}
|
|
120
|
+
.notice .dismiss {
|
|
121
|
+
margin-left: auto;
|
|
122
|
+
text-decoration: underline;
|
|
123
|
+
white-space: nowrap;
|
|
124
|
+
}
|
|
94
125
|
.frame { position: fixed; inset: 0; pointer-events: none; visibility: hidden; }
|
|
95
126
|
`;
|
package/dist/runner/project.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { removeGeneratedFiles } from "../browser/generated-files.js";
|
|
1
2
|
import { loadConfig, projectDir, setActiveProject } from "../config/config.js";
|
|
2
3
|
import { resetSecrets } from "../config/mask.js";
|
|
3
4
|
import { setRunSettings } from "../config/run-settings.js";
|
|
@@ -27,6 +28,7 @@ export async function withProject(options, work) {
|
|
|
27
28
|
return await work(run);
|
|
28
29
|
}
|
|
29
30
|
finally {
|
|
31
|
+
await removeGeneratedFiles().catch(() => undefined);
|
|
30
32
|
setRunSettings(undefined);
|
|
31
33
|
setActiveProject(undefined);
|
|
32
34
|
active = false;
|
package/dist/script/preload.js
CHANGED
|
@@ -16,11 +16,24 @@ export async function addPreload(connector, options) {
|
|
|
16
16
|
arguments: options.arguments.map((arg) => toLocalValue(arg)),
|
|
17
17
|
}),
|
|
18
18
|
});
|
|
19
|
+
let stopRerun;
|
|
20
|
+
if (needsRerun(connector, options)) {
|
|
21
|
+
try {
|
|
22
|
+
stopRerun = await rerunInNewRealms(connector, options);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
await connector
|
|
26
|
+
.send("script.removePreloadScript", { script })
|
|
27
|
+
.catch(() => undefined);
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
19
31
|
let removed = false;
|
|
20
32
|
const dispose = async () => {
|
|
21
33
|
if (removed)
|
|
22
34
|
return;
|
|
23
35
|
removed = true;
|
|
36
|
+
await stopRerun?.();
|
|
24
37
|
try {
|
|
25
38
|
await connector.send("script.removePreloadScript", { script });
|
|
26
39
|
}
|
|
@@ -42,6 +55,58 @@ export async function addPreload(connector, options) {
|
|
|
42
55
|
}
|
|
43
56
|
return { id: script, dispose };
|
|
44
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Chrome drops the messages of a channel that a preload script in a sandbox was given, while a channel handed to a
|
|
60
|
+
* call into the same sandbox works. So there the script is also run, with its channels, in every new sandbox realm.
|
|
61
|
+
*/
|
|
62
|
+
function needsRerun(connector, options) {
|
|
63
|
+
return (connector.browserName === "chrome" &&
|
|
64
|
+
options.sandbox !== undefined &&
|
|
65
|
+
(options.arguments?.length ?? 0) > 0);
|
|
66
|
+
}
|
|
67
|
+
async function rerunInNewRealms(connector, options) {
|
|
68
|
+
const { contexts, sandbox, source } = options;
|
|
69
|
+
const inScope = async (context) => {
|
|
70
|
+
if (!contexts)
|
|
71
|
+
return true;
|
|
72
|
+
if (contexts.includes(context))
|
|
73
|
+
return true;
|
|
74
|
+
const trees = await Promise.all(contexts.map((root) => connector.send("browsingContext.getTree", { root })));
|
|
75
|
+
return trees.some((tree) => tree.contexts.flatMap(flatten).includes(context));
|
|
76
|
+
};
|
|
77
|
+
const onRealm = (realm) => {
|
|
78
|
+
if (realm.type !== "window" || realm.sandbox !== sandbox || !realm.context)
|
|
79
|
+
return;
|
|
80
|
+
const context = realm.context;
|
|
81
|
+
void (async () => {
|
|
82
|
+
try {
|
|
83
|
+
if (!(await inScope(context)))
|
|
84
|
+
return;
|
|
85
|
+
await callFunction(connector, context, source, options.arguments ?? [], {
|
|
86
|
+
awaitPromise: false,
|
|
87
|
+
...(sandbox !== undefined && { sandbox }),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
// The document went away again, or the script threw: the registration covers what it can
|
|
92
|
+
logger.debug("Could not run a preload script in a new realm", { err });
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
};
|
|
96
|
+
connector.onEvent("script.realmCreated", onRealm);
|
|
97
|
+
let subscription;
|
|
98
|
+
try {
|
|
99
|
+
subscription = await connector.subscribe(["script.realmCreated"]);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
connector.offEvent("script.realmCreated", onRealm);
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
return async () => {
|
|
106
|
+
connector.offEvent("script.realmCreated", onRealm);
|
|
107
|
+
await subscription.unsubscribe().catch(() => undefined);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
45
110
|
/** Runs `source` once in every loaded context (iframes included) under `contexts` */
|
|
46
111
|
export async function runInLoadedContexts(connector, { source, contexts, sandbox, onRunError = "throw", arguments: args = [], }) {
|
|
47
112
|
const trees = contexts
|
package/dist/steps/edit.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
1
2
|
import { stepToSource } from "./emit.js";
|
|
2
3
|
import { parseSpec } from "./parse.js";
|
|
3
4
|
const lineStart = (source, offset) => source.lastIndexOf("\n", offset - 1) + 1;
|
|
@@ -73,6 +74,32 @@ function withFixtures(source, parsed, step) {
|
|
|
73
74
|
: `{ ${list} }`;
|
|
74
75
|
return splice(source, fixtures.at, fixtures.at, text);
|
|
75
76
|
}
|
|
77
|
+
const usesGeneratedFile = (step) => (step.kind === "setInputFiles" || step.kind === "dropFiles") &&
|
|
78
|
+
step.files.some((file) => "generate" in file);
|
|
79
|
+
const FRAMEWORK = "@itmetsam/samurai-framework";
|
|
80
|
+
/** Adds `generatedFile` to the framework import (or a new one) unless the file already imports it under its own name */
|
|
81
|
+
function withGeneratedFileImport(source) {
|
|
82
|
+
const file = ts.createSourceFile("spec.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
83
|
+
let frameworkImport;
|
|
84
|
+
for (const statement of file.statements) {
|
|
85
|
+
if (!ts.isImportDeclaration(statement))
|
|
86
|
+
continue;
|
|
87
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
88
|
+
statement.moduleSpecifier.text !== FRAMEWORK)
|
|
89
|
+
continue;
|
|
90
|
+
const named = statement.importClause?.namedBindings;
|
|
91
|
+
if (!named || !ts.isNamedImports(named))
|
|
92
|
+
continue;
|
|
93
|
+
if (named.elements.some((e) => e.name.text === "generatedFile" &&
|
|
94
|
+
(!e.propertyName || e.propertyName.text === e.name.text)))
|
|
95
|
+
return source;
|
|
96
|
+
frameworkImport ??= named;
|
|
97
|
+
}
|
|
98
|
+
if (!frameworkImport || frameworkImport.elements.length === 0)
|
|
99
|
+
return `import { generatedFile } from "${FRAMEWORK}";\n${source}`;
|
|
100
|
+
const last = frameworkImport.elements[frameworkImport.elements.length - 1];
|
|
101
|
+
return splice(source, last.end, last.end, ", generatedFile");
|
|
102
|
+
}
|
|
76
103
|
/**
|
|
77
104
|
* Applies one edit and returns the new source. Only the touched statement changes, plus the test's parameters
|
|
78
105
|
* when the step reads a fixture (`env`, `secrets`) they don't have yet: everything else, formatting and
|
|
@@ -84,10 +111,18 @@ export function applyEdit(source, edit) {
|
|
|
84
111
|
case "replace": {
|
|
85
112
|
const { start, end } = pickStep(parsed, edit.index);
|
|
86
113
|
// The parameters come before the body: adding to them leaves the step's offsets valid
|
|
87
|
-
|
|
114
|
+
const next = withFixtures(splice(source, start, end, stepToSource(edit.step)), parsed, edit.step);
|
|
115
|
+
// Last: the import shifts every offset
|
|
116
|
+
return usesGeneratedFile(edit.step)
|
|
117
|
+
? withGeneratedFileImport(next)
|
|
118
|
+
: next;
|
|
119
|
+
}
|
|
120
|
+
case "insert": {
|
|
121
|
+
const next = withFixtures(insertCode(source, parsed, edit.index, stepToSource(edit.step)), parsed, edit.step);
|
|
122
|
+
return usesGeneratedFile(edit.step)
|
|
123
|
+
? withGeneratedFileImport(next)
|
|
124
|
+
: next;
|
|
88
125
|
}
|
|
89
|
-
case "insert":
|
|
90
|
-
return withFixtures(insertCode(source, parsed, edit.index, stepToSource(edit.step)), parsed, edit.step);
|
|
91
126
|
case "remove":
|
|
92
127
|
return removeStep(source, parsed, edit.index);
|
|
93
128
|
case "move": {
|
package/dist/steps/emit.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { LocatorCall, LocatorSpec, Step, TextValue } from "./model.js";
|
|
1
|
+
import type { FileEntry, LocatorCall, LocatorSpec, Step, TextValue } from "./model.js";
|
|
2
|
+
/** The argument of `setInputFiles` / `dropFiles`: one entry bare, else an array */
|
|
3
|
+
export declare function filesToSource(files: readonly FileEntry[]): string;
|
|
2
4
|
export declare function callToSource(call: LocatorCall): string;
|
|
3
5
|
export declare function locatorToSource({ chain, fallbacks }: LocatorSpec): string;
|
|
4
6
|
export declare function valueToSource(value: TextValue): string;
|
package/dist/steps/emit.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
+
import { formatSize, sizeFromSource } from "./files.js";
|
|
1
2
|
const str = (value) => JSON.stringify(value);
|
|
3
|
+
function fileEntryToSource(entry) {
|
|
4
|
+
if ("path" in entry)
|
|
5
|
+
return str(entry.path);
|
|
6
|
+
const { name, size, type, sizeSource } = entry.generate;
|
|
7
|
+
const sizeText = sizeSource !== undefined && sizeFromSource(sizeSource) === size
|
|
8
|
+
? sizeSource
|
|
9
|
+
: str(formatSize(size));
|
|
10
|
+
return `generatedFile({ name: ${str(name)}, size: ${sizeText}${type === undefined ? "" : `, type: ${str(type)}`} })`;
|
|
11
|
+
}
|
|
12
|
+
/** The argument of `setInputFiles` / `dropFiles`: one entry bare, else an array */
|
|
13
|
+
export function filesToSource(files) {
|
|
14
|
+
return files.length === 1
|
|
15
|
+
? fileEntryToSource(files[0])
|
|
16
|
+
: `[${files.map(fileEntryToSource).join(", ")}]`;
|
|
17
|
+
}
|
|
2
18
|
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
3
19
|
function textOptions(call) {
|
|
4
20
|
const parts = [
|
|
@@ -73,6 +89,9 @@ export function stepToSource(step) {
|
|
|
73
89
|
return `await page.waitForTimeout(${step.ms});`;
|
|
74
90
|
case "screenshot":
|
|
75
91
|
return `await page.screenshot({ name: ${str(step.name)}${step.fullPage ? ", fullPage: true" : ""} });`;
|
|
92
|
+
case "setInputFiles":
|
|
93
|
+
case "dropFiles":
|
|
94
|
+
return `await ${locatorToSource(step.locator)}.${step.kind}(${filesToSource(step.files)});`;
|
|
76
95
|
case "custom":
|
|
77
96
|
return step.code;
|
|
78
97
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Bytes for a size: a whole number of bytes, or a text like "60 MB", "1.5 GB", "512 KB" (powers of 1024) */
|
|
2
|
+
export declare function parseSize(size: number | string): number | undefined;
|
|
3
|
+
/** A size in the largest unit that gives a whole number: 62914560 → "60 MB", 1536 → "1536 B" */
|
|
4
|
+
export declare function formatSize(bytes: number): string;
|
|
5
|
+
/** A size for people: the largest unit it reaches, one decimal at most ("300 MB", "1.3 GB") */
|
|
6
|
+
export declare function humanSize(bytes: number): string;
|
|
7
|
+
/** The bytes a size's source text means: a number literal, or a quoted size text */
|
|
8
|
+
export declare function sizeFromSource(text: string): number | undefined;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const UNITS = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
|
|
2
|
+
/** Bytes for a size: a whole number of bytes, or a text like "60 MB", "1.5 GB", "512 KB" (powers of 1024) */
|
|
3
|
+
export function parseSize(size) {
|
|
4
|
+
if (typeof size === "number")
|
|
5
|
+
return Number.isInteger(size) && size >= 0 ? size : undefined;
|
|
6
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)\s*$/i.exec(size);
|
|
7
|
+
if (!match)
|
|
8
|
+
return undefined;
|
|
9
|
+
return Math.round(Number(match[1]) * UNITS[match[2].toUpperCase()]);
|
|
10
|
+
}
|
|
11
|
+
/** A size in the largest unit that gives a whole number: 62914560 → "60 MB", 1536 → "1536 B" */
|
|
12
|
+
export function formatSize(bytes) {
|
|
13
|
+
for (const unit of ["GB", "MB", "KB"]) {
|
|
14
|
+
if (bytes >= UNITS[unit] && bytes % UNITS[unit] === 0)
|
|
15
|
+
return `${bytes / UNITS[unit]} ${unit}`;
|
|
16
|
+
}
|
|
17
|
+
return `${bytes} B`;
|
|
18
|
+
}
|
|
19
|
+
/** A size for people: the largest unit it reaches, one decimal at most ("300 MB", "1.3 GB") */
|
|
20
|
+
export function humanSize(bytes) {
|
|
21
|
+
for (const unit of ["GB", "MB", "KB"]) {
|
|
22
|
+
if (bytes >= UNITS[unit])
|
|
23
|
+
return `${Math.round((bytes / UNITS[unit]) * 10) / 10} ${unit}`;
|
|
24
|
+
}
|
|
25
|
+
return `${bytes} B`;
|
|
26
|
+
}
|
|
27
|
+
/** The bytes a size's source text means: a number literal, or a quoted size text */
|
|
28
|
+
export function sizeFromSource(text) {
|
|
29
|
+
if (/^\d+$/.test(text))
|
|
30
|
+
return parseSize(Number(text));
|
|
31
|
+
const quoted = /^(["'`])(.*)\1$/s.exec(text);
|
|
32
|
+
return quoted ? parseSize(quoted[2]) : undefined;
|
|
33
|
+
}
|
package/dist/steps/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { parseSpec } from "./parse.js";
|
|
2
2
|
export { applyEdit, type StepEdit } from "./edit.js";
|
|
3
|
-
export { stepToSource, locatorToSource, callToSource, valueToSource, } from "./emit.js";
|
|
3
|
+
export { stepToSource, locatorToSource, callToSource, filesToSource, valueToSource, } from "./emit.js";
|
|
4
|
+
export { formatSize, humanSize, parseSize, sizeFromSource } from "./files.js";
|
|
4
5
|
export { locatorFromSpec } from "./build.js";
|
|
5
|
-
export type { Expectation, LocatorCall, LocatorSpec, ParsedStep, ParsedTest, Step, TextMatcher, TextValue, } from "./model.js";
|
|
6
|
+
export type { Expectation, FileEntry, GeneratedFileSpec, LocatorCall, LocatorSpec, ParsedStep, ParsedTest, Step, TextMatcher, TextValue, } from "./model.js";
|
package/dist/steps/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { parseSpec } from "./parse.js";
|
|
2
2
|
export { applyEdit } from "./edit.js";
|
|
3
|
-
export { stepToSource, locatorToSource, callToSource, valueToSource, } from "./emit.js";
|
|
3
|
+
export { stepToSource, locatorToSource, callToSource, filesToSource, valueToSource, } from "./emit.js";
|
|
4
|
+
export { formatSize, humanSize, parseSize, sizeFromSource } from "./files.js";
|
|
4
5
|
export { locatorFromSpec } from "./build.js";
|
package/dist/steps/model.d.ts
CHANGED
|
@@ -59,6 +59,22 @@ export type Expectation = {
|
|
|
59
59
|
matcher: "toHaveCount";
|
|
60
60
|
expected: number;
|
|
61
61
|
};
|
|
62
|
+
/** A file generated when the step runs: seeded random bytes of `size`, never stored in the project */
|
|
63
|
+
export interface GeneratedFileSpec {
|
|
64
|
+
name: string;
|
|
65
|
+
/** Bytes */
|
|
66
|
+
size: number;
|
|
67
|
+
/** MIME type; `dropFiles` gives it to the page, `setInputFiles` gets it from the name's extension */
|
|
68
|
+
type?: string;
|
|
69
|
+
/** How the size is written in the source (`"60 MB"`, `1048576`); written back while it still means `size` */
|
|
70
|
+
sizeSource?: string;
|
|
71
|
+
}
|
|
72
|
+
/** A file a step sets or drops: one stored in the project (relative to the project folder), or a generated one */
|
|
73
|
+
export type FileEntry = {
|
|
74
|
+
path: string;
|
|
75
|
+
} | {
|
|
76
|
+
generate: GeneratedFileSpec;
|
|
77
|
+
};
|
|
62
78
|
/** The restricted set of statements the recorder writes. Anything else in a test is a `custom` step */
|
|
63
79
|
export type Step = {
|
|
64
80
|
kind: "goto";
|
|
@@ -95,6 +111,18 @@ export type Step = {
|
|
|
95
111
|
name: string;
|
|
96
112
|
fullPage: boolean;
|
|
97
113
|
}
|
|
114
|
+
/** Sets the files of an `<input type=file>`; no files clears it */
|
|
115
|
+
| {
|
|
116
|
+
kind: "setInputFiles";
|
|
117
|
+
locator: LocatorSpec;
|
|
118
|
+
files: FileEntry[];
|
|
119
|
+
}
|
|
120
|
+
/** Drops files on an element, as dragging them from the desktop does */
|
|
121
|
+
| {
|
|
122
|
+
kind: "dropFiles";
|
|
123
|
+
locator: LocatorSpec;
|
|
124
|
+
files: FileEntry[];
|
|
125
|
+
}
|
|
98
126
|
/** A statement the codec does not understand, kept verbatim */
|
|
99
127
|
| {
|
|
100
128
|
kind: "custom";
|
package/dist/steps/parse.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
|
+
import { parseSize } from "./files.js";
|
|
2
3
|
const TEXT_MATCHERS = ["toHaveText", "toContainText", "toHaveValue"];
|
|
3
4
|
function stringOf(node) {
|
|
4
5
|
return node &&
|
|
@@ -174,6 +175,77 @@ function readExpectation(matcher, args) {
|
|
|
174
175
|
}
|
|
175
176
|
return undefined;
|
|
176
177
|
}
|
|
178
|
+
/** `"path"` or `generatedFile({ name, size, type? })`; undefined for anything else */
|
|
179
|
+
function readFileEntry(node) {
|
|
180
|
+
const path = stringOf(node);
|
|
181
|
+
if (path !== undefined)
|
|
182
|
+
return { path };
|
|
183
|
+
if (!ts.isCallExpression(node) ||
|
|
184
|
+
!ts.isIdentifier(node.expression) ||
|
|
185
|
+
node.expression.text !== "generatedFile" ||
|
|
186
|
+
node.arguments.length !== 1 ||
|
|
187
|
+
!ts.isObjectLiteralExpression(node.arguments[0]))
|
|
188
|
+
return undefined;
|
|
189
|
+
let name;
|
|
190
|
+
let type;
|
|
191
|
+
let size;
|
|
192
|
+
let sizeSource;
|
|
193
|
+
for (const property of node.arguments[0].properties) {
|
|
194
|
+
if (!ts.isPropertyAssignment(property))
|
|
195
|
+
return undefined;
|
|
196
|
+
const value = property.initializer;
|
|
197
|
+
switch (property.name.getText()) {
|
|
198
|
+
case "name":
|
|
199
|
+
name = stringOf(value);
|
|
200
|
+
if (name === undefined)
|
|
201
|
+
return undefined;
|
|
202
|
+
break;
|
|
203
|
+
case "type":
|
|
204
|
+
type = stringOf(value);
|
|
205
|
+
if (type === undefined)
|
|
206
|
+
return undefined;
|
|
207
|
+
break;
|
|
208
|
+
case "size": {
|
|
209
|
+
const text = stringOf(value);
|
|
210
|
+
size =
|
|
211
|
+
text !== undefined
|
|
212
|
+
? parseSize(text)
|
|
213
|
+
: ts.isNumericLiteral(value)
|
|
214
|
+
? parseSize(Number(value.text))
|
|
215
|
+
: undefined;
|
|
216
|
+
if (size === undefined)
|
|
217
|
+
return undefined;
|
|
218
|
+
sizeSource = value.getText();
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
default:
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (name === undefined || size === undefined)
|
|
226
|
+
return undefined;
|
|
227
|
+
return {
|
|
228
|
+
generate: {
|
|
229
|
+
name,
|
|
230
|
+
size,
|
|
231
|
+
...(type !== undefined && { type }),
|
|
232
|
+
...(sizeSource !== undefined && { sizeSource }),
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** One file entry, or an array of them */
|
|
237
|
+
function readFiles(node) {
|
|
238
|
+
if (!node)
|
|
239
|
+
return undefined;
|
|
240
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
241
|
+
const entries = node.elements.map(readFileEntry);
|
|
242
|
+
return entries.every((entry) => entry !== undefined)
|
|
243
|
+
? entries
|
|
244
|
+
: undefined;
|
|
245
|
+
}
|
|
246
|
+
const entry = readFileEntry(node);
|
|
247
|
+
return entry && [entry];
|
|
248
|
+
}
|
|
177
249
|
/** The step a statement stands for, or undefined when it is outside the recorder's set */
|
|
178
250
|
function readStep(statement) {
|
|
179
251
|
if (!ts.isExpressionStatement(statement) ||
|
|
@@ -241,6 +313,13 @@ function readStep(statement) {
|
|
|
241
313
|
const value = readValue(args[0]);
|
|
242
314
|
return value ? { kind: "fill", locator, value } : undefined;
|
|
243
315
|
}
|
|
316
|
+
if ((name.text === "setInputFiles" || name.text === "dropFiles") &&
|
|
317
|
+
args.length === 1) {
|
|
318
|
+
const files = readFiles(args[0]);
|
|
319
|
+
return files
|
|
320
|
+
? { kind: name.text, locator, files }
|
|
321
|
+
: undefined;
|
|
322
|
+
}
|
|
244
323
|
return undefined;
|
|
245
324
|
}
|
|
246
325
|
function parseStatement(source, statement, sourceFile) {
|
|
@@ -25,12 +25,18 @@ export declare class BiDiConnector {
|
|
|
25
25
|
private currentId;
|
|
26
26
|
/** Set once the socket closed or failed: nothing sent after that gets an answer */
|
|
27
27
|
private closed;
|
|
28
|
+
/** Which browser is on the other end, when the launcher says; code that must treat browsers apart asks this */
|
|
29
|
+
browserName?: "firefox" | "chrome";
|
|
28
30
|
private webSocket;
|
|
29
31
|
private eventEmitter;
|
|
30
32
|
private commandTimeout;
|
|
31
33
|
private subscriptions;
|
|
32
34
|
constructor(ws: WebSocket, options?: ConnectorOptions);
|
|
33
35
|
static connect(url: string, options?: ConnectorOptions): Promise<BiDiConnector>;
|
|
36
|
+
/** Calls `listener` once when the socket closes or fails (not when `kill()` ends it) */
|
|
37
|
+
onClosed(listener: () => void): void;
|
|
38
|
+
private closeListeners;
|
|
39
|
+
private notifyClosed;
|
|
34
40
|
private onWebsocketClose;
|
|
35
41
|
private onWebsocketError;
|
|
36
42
|
private get isClosed();
|
|
@@ -22,12 +22,15 @@ export class BiDiConnector {
|
|
|
22
22
|
this.currentId = 0;
|
|
23
23
|
/** Set once the socket closed or failed: nothing sent after that gets an answer */
|
|
24
24
|
this.closed = false;
|
|
25
|
+
this.closeListeners = new Set();
|
|
25
26
|
this.onWebsocketClose = () => {
|
|
26
27
|
this.closed = true;
|
|
28
|
+
this.notifyClosed();
|
|
27
29
|
this.rejectAll((pending) => new ConnectionClosedError(`Websocket closed${pendingSuffix(pending)}`));
|
|
28
30
|
};
|
|
29
31
|
this.onWebsocketError = (ev) => {
|
|
30
32
|
this.closed = true;
|
|
33
|
+
this.notifyClosed();
|
|
31
34
|
this.rejectAll((pending) => new ConnectionClosedError(`Websocket error${pendingSuffix(pending)}`, {
|
|
32
35
|
cause: ev,
|
|
33
36
|
}));
|
|
@@ -84,6 +87,16 @@ export class BiDiConnector {
|
|
|
84
87
|
});
|
|
85
88
|
});
|
|
86
89
|
}
|
|
90
|
+
/** Calls `listener` once when the socket closes or fails (not when `kill()` ends it) */
|
|
91
|
+
onClosed(listener) {
|
|
92
|
+
this.closeListeners.add(listener);
|
|
93
|
+
}
|
|
94
|
+
notifyClosed() {
|
|
95
|
+
const listeners = [...this.closeListeners];
|
|
96
|
+
this.closeListeners.clear();
|
|
97
|
+
for (const listener of listeners)
|
|
98
|
+
listener();
|
|
99
|
+
}
|
|
87
100
|
get isClosed() {
|
|
88
101
|
// A socket that is closing or closed (readyState 2 or 3) has already lost the browser
|
|
89
102
|
const state = this.webSocket.readyState;
|
package/dist/types/browser.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export type SupportedBrowser = "firefox";
|
|
1
|
+
export type SupportedBrowser = "firefox" | "chrome";
|