@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.
Files changed (83) hide show
  1. package/README.md +31 -12
  2. package/dist/api.d.ts +1 -0
  3. package/dist/api.js +1 -0
  4. package/dist/browser/browser-finder.js +2 -2
  5. package/dist/browser/browser.d.ts +11 -15
  6. package/dist/browser/browser.js +79 -58
  7. package/dist/browser/chrome-launch.d.ts +57 -0
  8. package/dist/browser/chrome-launch.js +127 -0
  9. package/dist/browser/download-folders.d.ts +27 -0
  10. package/dist/browser/download-folders.js +97 -0
  11. package/dist/browser/file-chooser.d.ts +4 -3
  12. package/dist/browser/file-chooser.js +9 -8
  13. package/dist/browser/generated-files.d.ts +30 -0
  14. package/dist/browser/generated-files.js +116 -0
  15. package/dist/browser/page-logs.d.ts +2 -0
  16. package/dist/browser/page-logs.js +25 -5
  17. package/dist/browser/page.d.ts +37 -0
  18. package/dist/browser/page.js +129 -2
  19. package/dist/browser/screenshot.js +16 -2
  20. package/dist/browser/session-capabilities.d.ts +12 -0
  21. package/dist/browser/session-capabilities.js +8 -0
  22. package/dist/browsers/index.d.ts +2 -2
  23. package/dist/browsers/index.js +2 -2
  24. package/dist/browsers/overrides.d.ts +2 -0
  25. package/dist/browsers/overrides.js +2 -0
  26. package/dist/browsers/pins.d.ts +1 -1
  27. package/dist/browsers/pins.js +2 -2
  28. package/dist/browsers/resolve.d.ts +2 -0
  29. package/dist/browsers/resolve.js +14 -2
  30. package/dist/browsers/store.d.ts +2 -0
  31. package/dist/browsers/store.js +8 -0
  32. package/dist/cli/main.d.ts +1 -1
  33. package/dist/cli/main.js +20 -5
  34. package/dist/locator/locator.d.ts +17 -5
  35. package/dist/locator/locator.js +77 -10
  36. package/dist/network/data-collector.d.ts +1 -0
  37. package/dist/network/data-collector.js +23 -5
  38. package/dist/network/network-tracker.d.ts +8 -1
  39. package/dist/network/network-tracker.js +42 -3
  40. package/dist/network/router.d.ts +5 -0
  41. package/dist/network/router.js +33 -11
  42. package/dist/project/files-store.d.ts +28 -0
  43. package/dist/project/files-store.js +106 -0
  44. package/dist/project/index.d.ts +1 -0
  45. package/dist/project/index.js +1 -0
  46. package/dist/recorder/apply.js +8 -6
  47. package/dist/recorder/files-capture.d.ts +26 -0
  48. package/dist/recorder/files-capture.js +92 -0
  49. package/dist/recorder/files-reader.d.ts +7 -0
  50. package/dist/recorder/files-reader.js +25 -0
  51. package/dist/recorder/index.d.ts +1 -1
  52. package/dist/recorder/index.js +1 -1
  53. package/dist/recorder/normalise.d.ts +26 -2
  54. package/dist/recorder/normalise.js +48 -1
  55. package/dist/recorder/recorder.d.ts +38 -0
  56. package/dist/recorder/recorder.js +160 -1
  57. package/dist/recorder/session.d.ts +14 -1
  58. package/dist/recorder/session.js +36 -9
  59. package/dist/recorder/toolbar/controller.d.ts +15 -5
  60. package/dist/recorder/toolbar/controller.js +61 -7
  61. package/dist/recorder/toolbar/protocol.d.ts +10 -0
  62. package/dist/recorder/toolbar/source.d.ts +1 -1
  63. package/dist/recorder/toolbar/source.js +35 -2
  64. package/dist/recorder/toolbar/styles.d.ts +1 -1
  65. package/dist/recorder/toolbar/styles.js +31 -0
  66. package/dist/runner/project.js +2 -0
  67. package/dist/script/preload.js +65 -0
  68. package/dist/steps/edit.js +38 -3
  69. package/dist/steps/emit.d.ts +3 -1
  70. package/dist/steps/emit.js +19 -0
  71. package/dist/steps/files.d.ts +8 -0
  72. package/dist/steps/files.js +33 -0
  73. package/dist/steps/index.d.ts +3 -2
  74. package/dist/steps/index.js +2 -1
  75. package/dist/steps/model.d.ts +28 -0
  76. package/dist/steps/parse.js +79 -0
  77. package/dist/transport/bidi-connection.d.ts +6 -0
  78. package/dist/transport/bidi-connection.js +13 -0
  79. package/dist/types/browser.d.ts +1 -1
  80. package/dist/types/config.d.ts +7 -2
  81. package/package.json +3 -2
  82. package/dist/browser/file-paths.d.ts +0 -4
  83. 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
  `;
@@ -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;
@@ -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
@@ -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
- return withFixtures(splice(source, start, end, stepToSource(edit.step)), parsed, edit.step);
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": {
@@ -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;
@@ -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
+ }
@@ -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";
@@ -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";
@@ -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";
@@ -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;
@@ -1 +1 @@
1
- export type SupportedBrowser = "firefox";
1
+ export type SupportedBrowser = "firefox" | "chrome";