@itmetsam/samurai-framework 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +2 -2
  2. package/bin/samurai.mjs +25 -0
  3. package/dist/browser/evidence.d.ts +19 -0
  4. package/dist/browser/evidence.js +35 -0
  5. package/dist/browser/page.d.ts +6 -0
  6. package/dist/browser/page.js +20 -3
  7. package/dist/browser/screenshot.d.ts +2 -0
  8. package/dist/browser/screenshot.js +5 -2
  9. package/dist/cli/browsers.d.ts +1 -1
  10. package/dist/cli/browsers.js +2 -4
  11. package/dist/cli/main.js +17 -3
  12. package/dist/project/config-edit.d.ts +16 -0
  13. package/dist/project/config-edit.js +218 -0
  14. package/dist/project/index.d.ts +3 -0
  15. package/dist/project/index.js +3 -0
  16. package/dist/project/secrets-file.d.ts +8 -0
  17. package/dist/project/secrets-file.js +69 -0
  18. package/dist/project/variables.d.ts +6 -0
  19. package/dist/project/variables.js +17 -0
  20. package/dist/recorder/candidates.d.ts +7 -0
  21. package/dist/recorder/candidates.js +118 -0
  22. package/dist/recorder/capture.d.ts +8 -1
  23. package/dist/recorder/capture.js +25 -116
  24. package/dist/recorder/index.d.ts +1 -1
  25. package/dist/recorder/normalise.d.ts +28 -1
  26. package/dist/recorder/normalise.js +40 -1
  27. package/dist/recorder/recorder.d.ts +54 -0
  28. package/dist/recorder/recorder.js +195 -10
  29. package/dist/recorder/session.d.ts +17 -0
  30. package/dist/recorder/session.js +62 -8
  31. package/dist/recorder/toolbar/controller.d.ts +136 -0
  32. package/dist/recorder/toolbar/controller.js +471 -0
  33. package/dist/recorder/toolbar/inspect.d.ts +12 -0
  34. package/dist/recorder/toolbar/inspect.js +94 -0
  35. package/dist/recorder/toolbar/protocol.d.ts +135 -0
  36. package/dist/recorder/toolbar/protocol.js +4 -0
  37. package/dist/recorder/toolbar/source.d.ts +32 -0
  38. package/dist/recorder/toolbar/source.js +856 -0
  39. package/dist/recorder/toolbar/styles.d.ts +6 -0
  40. package/dist/recorder/toolbar/styles.js +95 -0
  41. package/dist/runner/reporter.d.ts +5 -1
  42. package/dist/runner/reporter.js +4 -3
  43. package/dist/runner/test-runner.d.ts +9 -0
  44. package/dist/runner/test-runner.js +39 -4
  45. package/dist/steps/edit.d.ts +3 -2
  46. package/dist/steps/edit.js +28 -4
  47. package/dist/steps/emit.js +4 -0
  48. package/dist/steps/model.d.ts +21 -0
  49. package/dist/steps/parse.js +45 -3
  50. package/dist/transport/bidi-connection.js +32 -1
  51. package/dist/types/bidi-modules/script.d.ts +20 -3
  52. package/dist/types/test.d.ts +4 -0
  53. package/package.json +5 -1
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The toolbar's styles, scoped to its shadow root: the rail, the collapsed chip, the panels, the picker
3
+ * outline and the toast, as in the approved mockups. Everything inside is `position: fixed` against the
4
+ * viewport; the host itself takes no space.
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";
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The toolbar's styles, scoped to its shadow root: the rail, the collapsed chip, the panels, the picker
3
+ * outline and the toast, as in the approved mockups. Everything inside is `position: fixed` against the
4
+ * viewport; the host itself takes no space.
5
+ */
6
+ export const TOOLBAR_CSS = `
7
+ :host { all: initial; }
8
+ * { box-sizing: border-box; }
9
+ .ui { font: 12px/1.3 system-ui, -apple-system, "Segoe UI", sans-serif; color: #222; color-scheme: light; }
10
+ button, input, select { font: inherit; color: inherit; margin: 0; }
11
+ button { appearance: none; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }
12
+ button:disabled { cursor: default; }
13
+ code { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; }
14
+
15
+ .rail, .chip { position: fixed; z-index: 3; box-shadow: 0 6px 20px rgba(0,0,0,.28); background: #17181c; color: #e9eaee; user-select: none; }
16
+ .rail { display: flex; flex-direction: column; gap: 2px; border-radius: 10px; padding: 5px; width: 112px; }
17
+ .rail.wide { width: 150px; }
18
+ .top { display: flex; align-items: center; justify-content: space-between; color: #6b6e78; padding: 0 2px 2px; }
19
+ [data-grip] { cursor: grab; flex: 1; touch-action: none; }
20
+ [data-grip]:active { cursor: grabbing; }
21
+ .chev { color: #6b6e78; padding: 0 3px; border-radius: 4px; }
22
+ .chev:hover { color: #e9eaee; }
23
+ .st { display: flex; align-items: center; gap: 6px; padding: 4px 6px; font-weight: 600; }
24
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: #ff4d4f; box-shadow: 0 0 0 3px rgba(255,77,79,.25); flex: none; }
25
+ .dot.pause { background: #f5b301; box-shadow: 0 0 0 3px rgba(245,179,1,.25); }
26
+ .b { padding: 5px 7px; border-radius: 6px; display: flex; align-items: center; gap: 6px; width: 100%; }
27
+ .b:hover:not(:disabled) { background: #2a2c33; }
28
+ .b.on, .b.on:hover:not(:disabled) { background: #2f6fed; color: #fff; }
29
+ .b:disabled { opacity: .38; }
30
+ .sep { height: 1px; background: #34363d; margin: 2px 3px; }
31
+ .stop { color: #ff8a8c; }
32
+ .ico { width: 14px; text-align: center; flex: none; }
33
+ .note { font-size: 10.5px; color: #c9cbd2; padding: 4px 6px 2px; line-height: 1.35; }
34
+ .ask { display: flex; gap: 4px; padding: 2px 6px 4px; }
35
+ .ask button { flex: 1; text-align: center; border-radius: 5px; padding: 3px 0; background: #2a2c33; }
36
+ .ask button.yes { background: #2f6fed; color: #fff; font-weight: 600; }
37
+
38
+ .chip { display: flex; align-items: center; gap: 7px; border-radius: 999px; padding: 6px 10px 6px 8px; font-weight: 600; }
39
+ .chip [data-grip] { color: #6b6e78; flex: none; }
40
+ .chip .chev { color: #9a9ca5; }
41
+
42
+ .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; }
43
+ .panel.wide { width: 236px; }
44
+ .h { padding: 8px 10px; border-bottom: 1px solid #eceef1; font-weight: 600; display: flex; justify-content: space-between; gap: 8px; }
45
+ .h.stack { display: block; }
46
+ .h code { font-size: 11px; color: #555; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
47
+ .h small { display: block; font-weight: 400; color: #777; margin-top: 2px; }
48
+ .msg { padding: 10px; color: #555; }
49
+ .err { padding: 6px 10px; color: #c22b2e; }
50
+ .opts { padding: 6px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
51
+ .o { border: 1px solid #e3e5e8; border-radius: 6px; padding: 5px 6px; }
52
+ .o:hover:not(:disabled) { border-color: #b9c6e4; }
53
+ .o.sel { border-color: #2f6fed; background: #eef3fe; color: #1d4fc4; font-weight: 600; }
54
+ .o:disabled { opacity: .45; }
55
+ .val { margin: 2px 8px 8px; display: flex; flex-direction: column; gap: 4px; }
56
+ .in { width: 100%; border: 1px solid #cfd3d8; border-radius: 4px; padding: 5px 6px; background: #fff; outline: none; }
57
+ .in:focus { border-color: #2f6fed; box-shadow: 0 0 0 2px rgba(47,111,237,.15); }
58
+ .in.code { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; }
59
+ .row { display: flex; justify-content: space-between; align-items: center; padding: 0 8px 8px; }
60
+ .neg { color: #555; display: flex; align-items: center; gap: 4px; }
61
+ .add { background: #2f6fed; color: #fff; border-radius: 6px; padding: 5px 12px; font-weight: 600; text-align: center; }
62
+ .add:disabled { opacity: .5; }
63
+ .cancel { padding: 5px 10px; color: #555; }
64
+ .foot { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 10px; border-top: 1px solid #eceef1; }
65
+ .foot.plain { border-top: 0; padding-top: 10px; }
66
+
67
+ .search { display: block; margin: 6px 8px; width: calc(100% - 16px); }
68
+ .grp { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: #888; padding: 6px 10px 2px; }
69
+ .it { display: flex; justify-content: space-between; gap: 8px; padding: 5px 10px; width: 100%; }
70
+ .it:hover, .it.sel { background: #eef3fe; }
71
+ .it .v { color: #777; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
72
+ .warn { color: #b26a00; }
73
+ .empty { padding: 4px 10px; color: #777; }
74
+ .act { padding: 6px 10px; color: #1d4fc4; font-weight: 600; width: 100%; }
75
+ .act:hover { background: #eef3fe; }
76
+
77
+ .seg { display: flex; margin: 8px 10px 0; border: 1px solid #cfd3d8; border-radius: 6px; overflow: hidden; }
78
+ .seg button { flex: 1; text-align: center; padding: 4px; }
79
+ .seg .sel { background: #eef3fe; color: #1d4fc4; font-weight: 600; }
80
+ .lbl { font-size: 11px; color: #666; padding: 6px 10px 2px; }
81
+ .f { padding: 0 10px; }
82
+ .field-error { color: #c22b2e; font-size: 11px; padding: 3px 10px 0; }
83
+ .hint { padding: 6px 10px 0; color: #666; font-size: 11px; line-height: 1.35; }
84
+ .hint code { font-size: 10.5px; }
85
+
86
+ .radio { padding: 6px 10px; display: flex; gap: 7px; align-items: center; }
87
+ .radio input[type=radio] { accent-color: #2f6fed; margin: 0; }
88
+ .sub { color: #777; font-size: 11px; padding: 0 10px 2px 30px; margin-top: -4px; }
89
+ .num { width: 52px; border: 1px solid #cfd3d8; border-radius: 4px; padding: 3px 5px; }
90
+
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
+ .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
+ .toast.gone { opacity: 0; }
94
+ .frame { position: fixed; inset: 0; pointer-events: none; visibility: hidden; }
95
+ `;
@@ -36,8 +36,12 @@ export default class TestReporter {
36
36
  onStart(total?: number): void;
37
37
  onEnd(): Promise<TestSummary>;
38
38
  onTestStart(test: RegisteredTestCase): void;
39
- onTestEnd(test: RegisteredTestCase, error?: TestError, logs?: {
39
+ onTestEnd(test: RegisteredTestCase, error?: TestError, extras?: {
40
40
  logs?: TestLogEntry[];
41
41
  logsDropped?: number;
42
+ screenshots?: {
43
+ name: string;
44
+ path: string;
45
+ }[];
42
46
  }): void;
43
47
  }
@@ -65,7 +65,7 @@ export default class TestReporter {
65
65
  });
66
66
  this.emit({ type: "test-start", name: test.name, file: test.file });
67
67
  }
68
- onTestEnd(test, error, logs) {
68
+ onTestEnd(test, error, extras) {
69
69
  const key = keyOf(test);
70
70
  performance.mark(`${key}-finish`);
71
71
  const duration = performance.measure("test-duration", `${key}-start`, `${key}-finish`).duration;
@@ -74,8 +74,9 @@ export default class TestReporter {
74
74
  duration,
75
75
  status: error ? "failed" : "success",
76
76
  ...(error ? error : undefined),
77
- ...(logs?.logs && { logs: logs.logs }),
78
- ...(logs?.logsDropped && { logsDropped: logs.logsDropped }),
77
+ ...(extras?.logs && { logs: extras.logs }),
78
+ ...(extras?.logsDropped && { logsDropped: extras.logsDropped }),
79
+ ...(extras?.screenshots?.length && { screenshots: extras.screenshots }),
79
80
  });
80
81
  this.testResults.set(key, result);
81
82
  this.emit({ type: "test-end", name: test.name, file: test.file, result });
@@ -31,6 +31,8 @@ export default class TestRunner {
31
31
  private run;
32
32
  private group?;
33
33
  private options;
34
+ /** The real `srcDir` of this run, set by `start`; test screenshots are saved under paths relative to it */
35
+ private srcDir;
34
36
  constructor(testFiles: string[], run: PreparedRun, group?: SamuraiGroup, options?: RunnerOptions);
35
37
  static init(run: PreparedRun, group?: SamuraiGroup, options?: RunnerOptions): Promise<TestRunner>;
36
38
  /**
@@ -51,4 +53,11 @@ export default class TestRunner {
51
53
  start(): Promise<import("../types/test.js").TestSummary>;
52
54
  private executeTestCase;
53
55
  }
56
+ /**
57
+ * The folder of a test's screenshots below `screenshots/`: its spec file without extension, then its cleaned
58
+ * name. A spec outside srcDir loses the `..` (and a drive or root) its relative path starts with, so it stays
59
+ * below `screenshots/`.
60
+ * @internal Exported for tests
61
+ */
62
+ export declare function testDirOf(test: RegisteredTestCase, srcDir: string): string;
54
63
  export { describe, test } from "./registry.js";
@@ -12,6 +12,7 @@ import { createEnvFixture } from "../config/variables.js";
12
12
  import { createSecretsFixture } from "../config/secrets.js";
13
13
  import TestReporter, {} from "./reporter.js";
14
14
  import Page from "../browser/page.js";
15
+ import { cleanName, Evidence } from "../browser/evidence.js";
15
16
  import { takePendingAssertions } from "../assert/expect.js";
16
17
  import { toTestError } from "./test-error.js";
17
18
  import { ConnectionClosedError } from "../transport/bidi-connection.js";
@@ -30,6 +31,8 @@ export function isSelected(name, { grep, testNames }) {
30
31
  }
31
32
  export default class TestRunner {
32
33
  constructor(testFiles, run, group, options = {}) {
34
+ /** The real `srcDir` of this run, set by `start`; test screenshots are saved under paths relative to it */
35
+ this.srcDir = "";
33
36
  this.testFiles = testFiles;
34
37
  this.run = run;
35
38
  this.group = group;
@@ -82,9 +85,7 @@ export default class TestRunner {
82
85
  /** The config's groups, resolved against the tests the specs register */
83
86
  async resolveAll(tests) {
84
87
  const config = await loadConfig();
85
- // Real paths: registered test files come from stack traces, which resolve symlinks
86
- const dir = realPath(projectDir());
87
- const srcDir = realPath(path.resolve(dir, config.srcDir ?? DEFAULT_SRC_DIR));
88
+ const { dir, srcDir } = realDirs(config.srcDir);
88
89
  const groups = resolveGroups(config, tests.map(({ file, name }) => ({
89
90
  file: toPosix(path.relative(srcDir, file)),
90
91
  name,
@@ -116,6 +117,7 @@ export default class TestRunner {
116
117
  ...(onEvent && { onEvent }),
117
118
  });
118
119
  const selected = await this.register();
120
+ this.srcDir = realDirs((await loadConfig()).srcDir).srcDir;
119
121
  // A missing browser is one clear error before the run, not a failure per test; a fresh build's slow
120
122
  // first start happens here, outside any test's timeout
121
123
  if (selected.length > 0)
@@ -136,11 +138,15 @@ export default class TestRunner {
136
138
  reporter.onTestStart(test);
137
139
  // A test ends once, whichever of finishing, failing, timing out, an abort or a closed browser comes first
138
140
  let ended = false;
141
+ let evidence;
139
142
  const finish = (error, logs) => {
140
143
  if (ended)
141
144
  return;
142
145
  ended = true;
143
- reporter.onTestEnd(test, error, logs);
146
+ reporter.onTestEnd(test, error, {
147
+ ...logs,
148
+ screenshots: evidence?.screenshots,
149
+ });
144
150
  };
145
151
  let browserClosed = false;
146
152
  /** Set once the runner itself closes the browser */
@@ -148,6 +154,10 @@ export default class TestRunner {
148
154
  const selectedBrowser = (await readConfig("browser")) ?? "firefox";
149
155
  const { settings, secrets } = this.run;
150
156
  const timeout = settings.timeout;
157
+ const { reportPath } = this.options;
158
+ const resultDir = typeof reportPath === "string"
159
+ ? path.dirname(reportPath)
160
+ : path.join(projectDir(), "result");
151
161
  let browser;
152
162
  let page;
153
163
  let timeoutTimer;
@@ -195,6 +205,8 @@ export default class TestRunner {
195
205
  resolve();
196
206
  });
197
207
  page = launched.page;
208
+ evidence = new Evidence(resultDir, testDirOf(test, this.srcDir));
209
+ launched.page.setEvidence(evidence);
198
210
  const fixtures = {
199
211
  page: launched.page,
200
212
  browser: launched.browser,
@@ -241,6 +253,29 @@ export default class TestRunner {
241
253
  await browser?.close();
242
254
  }
243
255
  }
256
+ /** The project and source folders as real paths: registered test files come from stack traces, which resolve symlinks */
257
+ function realDirs(configuredSrcDir) {
258
+ const dir = realPath(projectDir());
259
+ const srcDir = realPath(path.resolve(dir, configuredSrcDir ?? DEFAULT_SRC_DIR));
260
+ return { dir, srcDir };
261
+ }
262
+ /**
263
+ * The folder of a test's screenshots below `screenshots/`: its spec file without extension, then its cleaned
264
+ * name. A spec outside srcDir loses the `..` (and a drive or root) its relative path starts with, so it stays
265
+ * below `screenshots/`.
266
+ * @internal Exported for tests
267
+ */
268
+ export function testDirOf(test, srcDir) {
269
+ const file = toPosix(path.relative(srcDir, test.file))
270
+ .replace(/\.[cm]?[jt]sx?$/, "")
271
+ .split("/")
272
+ .filter((segment) => segment !== "" &&
273
+ segment !== "." &&
274
+ segment !== ".." &&
275
+ !/^[A-Za-z]:$/.test(segment))
276
+ .join("/");
277
+ return `${file}/${cleanName(test.name)}`;
278
+ }
244
279
  /** Reads the page's log once the test body is done and applies config `logs` (see judgePageLogs) */
245
280
  async function judgeLogs(page, testFailed) {
246
281
  if (!page)
@@ -24,7 +24,8 @@ export type StepEdit = {
24
24
  to: number;
25
25
  };
26
26
  /**
27
- * Applies one edit and returns the new source. Only the touched statement changes: everything else,
28
- * formatting and comments included, stays byte for byte.
27
+ * Applies one edit and returns the new source. Only the touched statement changes, plus the test's parameters
28
+ * when the step reads a fixture (`env`, `secrets`) they don't have yet: everything else, formatting and
29
+ * comments included, stays byte for byte.
29
30
  */
30
31
  export declare function applyEdit(source: string, edit: StepEdit): string;
@@ -51,19 +51,43 @@ function removeStep(source, parsed, index) {
51
51
  const { anchor, end } = pickStep(parsed, index);
52
52
  return splice(source, lineStart(source, anchor), lineEnd(source, end), "");
53
53
  }
54
+ /** The fixtures a step's statement reads */
55
+ function fixturesOf(step) {
56
+ if (step.kind !== "fill" || step.value.kind === "literal")
57
+ return [];
58
+ return [step.value.kind === "env" ? "env" : "secrets"];
59
+ }
60
+ /** Adds the fixtures the step reads to the test's parameters, when they aren't there yet */
61
+ function withFixtures(source, parsed, step) {
62
+ const { fixtures } = parsed;
63
+ if (!fixtures)
64
+ return source;
65
+ const missing = fixturesOf(step).filter((name) => !fixtures.names.includes(name));
66
+ if (missing.length === 0)
67
+ return source;
68
+ const list = missing.join(", ");
69
+ const text = fixtures.shape === "list"
70
+ ? `, ${list}`
71
+ : fixtures.shape === "empty"
72
+ ? ` ${list} `
73
+ : `{ ${list} }`;
74
+ return splice(source, fixtures.at, fixtures.at, text);
75
+ }
54
76
  /**
55
- * Applies one edit and returns the new source. Only the touched statement changes: everything else,
56
- * formatting and comments included, stays byte for byte.
77
+ * Applies one edit and returns the new source. Only the touched statement changes, plus the test's parameters
78
+ * when the step reads a fixture (`env`, `secrets`) they don't have yet: everything else, formatting and
79
+ * comments included, stays byte for byte.
57
80
  */
58
81
  export function applyEdit(source, edit) {
59
82
  const parsed = pickTest(source, edit.test);
60
83
  switch (edit.op) {
61
84
  case "replace": {
62
85
  const { start, end } = pickStep(parsed, edit.index);
63
- return splice(source, start, end, stepToSource(edit.step));
86
+ // 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);
64
88
  }
65
89
  case "insert":
66
- return insertCode(source, parsed, edit.index, stepToSource(edit.step));
90
+ return withFixtures(insertCode(source, parsed, edit.index, stepToSource(edit.step)), parsed, edit.step);
67
91
  case "remove":
68
92
  return removeStep(source, parsed, edit.index);
69
93
  case "move": {
@@ -69,6 +69,10 @@ export function stepToSource(step) {
69
69
  return `await expect(${locatorToSource(step.locator)})${step.not ? ".not" : ""}.${expectationToSource(step.expectation)};`;
70
70
  case "waitForNetworkIdle":
71
71
  return "await page.waitForNetworkIdle();";
72
+ case "waitForTimeout":
73
+ return `await page.waitForTimeout(${step.ms});`;
74
+ case "screenshot":
75
+ return `await page.screenshot({ name: ${str(step.name)}${step.fullPage ? ", fullPage: true" : ""} });`;
72
76
  case "custom":
73
77
  return step.code;
74
78
  }
@@ -84,6 +84,17 @@ export type Step = {
84
84
  } | {
85
85
  kind: "waitForNetworkIdle";
86
86
  }
87
+ /** A fixed pause, in milliseconds */
88
+ | {
89
+ kind: "waitForTimeout";
90
+ ms: number;
91
+ }
92
+ /** Saves the page as evidence under the run's results; never compares */
93
+ | {
94
+ kind: "screenshot";
95
+ name: string;
96
+ fullPage: boolean;
97
+ }
87
98
  /** A statement the codec does not understand, kept verbatim */
88
99
  | {
89
100
  kind: "custom";
@@ -106,4 +117,14 @@ export interface ParsedTest {
106
117
  /** Offset just after the `{` of the test body, and of its closing `}` */
107
118
  bodyStart: number;
108
119
  bodyEnd: number;
120
+ /**
121
+ * The fixtures the test's callback destructures, and the offset where another one goes: after the last one
122
+ * (`list`), inside an empty `{}` (`empty`) or inside the empty `()` of a callback without parameters (`none`).
123
+ * Undefined when the parameter can't take one: a plain identifier, or a rest element
124
+ */
125
+ fixtures?: {
126
+ names: string[];
127
+ at: number;
128
+ shape: "list" | "empty" | "none";
129
+ };
109
130
  }
@@ -190,9 +190,28 @@ function readStep(statement) {
190
190
  const url = stringOf(args[0]);
191
191
  return url === undefined ? undefined : { kind: "goto", url };
192
192
  }
193
- return name.text === "waitForNetworkIdle" && args.length === 0
194
- ? { kind: "waitForNetworkIdle" }
195
- : undefined;
193
+ if (name.text === "waitForNetworkIdle" && args.length === 0)
194
+ return { kind: "waitForNetworkIdle" };
195
+ if (name.text === "waitForTimeout" &&
196
+ args.length === 1 &&
197
+ ts.isNumericLiteral(args[0])) {
198
+ const ms = Number(args[0].text);
199
+ return Number.isInteger(ms) ? { kind: "waitForTimeout", ms } : undefined;
200
+ }
201
+ if (name.text === "screenshot" && args.length === 1) {
202
+ const options = readObject(args[0], ["name", "fullPage"]);
203
+ if (!options || typeof options.name !== "string")
204
+ return undefined;
205
+ if (options.fullPage !== undefined &&
206
+ typeof options.fullPage !== "boolean")
207
+ return undefined;
208
+ return {
209
+ kind: "screenshot",
210
+ name: options.name,
211
+ fullPage: options.fullPage === true,
212
+ };
213
+ }
214
+ return undefined;
196
215
  }
197
216
  // expect(locator).matcher(…) and expect(locator).not.matcher(…)
198
217
  const negated = ts.isPropertyAccessExpression(target) && target.name.text === "not";
@@ -238,6 +257,28 @@ function parseStatement(source, statement, sourceFile) {
238
257
  anchor: comments?.[0]?.pos ?? start,
239
258
  };
240
259
  }
260
+ function callbackFixtures(callback, sourceFile) {
261
+ const [first] = callback.parameters;
262
+ if (!first) {
263
+ const open = callback
264
+ .getChildren(sourceFile)
265
+ .find((child) => child.kind === ts.SyntaxKind.OpenParenToken);
266
+ return open ? { names: [], at: open.end, shape: "none" } : undefined;
267
+ }
268
+ const pattern = first.name;
269
+ if (!ts.isObjectBindingPattern(pattern))
270
+ return undefined;
271
+ if (pattern.elements.some((element) => element.dotDotDotToken))
272
+ return undefined;
273
+ const last = pattern.elements[pattern.elements.length - 1];
274
+ if (!last)
275
+ return { names: [], at: pattern.getStart(sourceFile) + 1, shape: "empty" };
276
+ return {
277
+ names: pattern.elements.map(({ propertyName, name }) => (propertyName ?? name).getText(sourceFile)),
278
+ at: last.end,
279
+ shape: "list",
280
+ };
281
+ }
241
282
  function callbackBody(node) {
242
283
  return node &&
243
284
  (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
@@ -261,6 +302,7 @@ export function parseSpec(source) {
261
302
  steps: body.statements.map((statement) => parseStatement(source, statement, sourceFile)),
262
303
  bodyStart: body.getStart(sourceFile) + 1,
263
304
  bodyEnd: body.end - 1,
305
+ fixtures: callbackFixtures(node.arguments[1], sourceFile),
264
306
  });
265
307
  return;
266
308
  }
@@ -34,7 +34,7 @@ export class BiDiConnector {
34
34
  };
35
35
  this.messageListener = (ev) => {
36
36
  const message = JSON.parse(ev.data);
37
- logger.debug("Received message from BiDi Websocket: ", message);
37
+ logger.debug("Received message from BiDi Websocket: ", forLog(message));
38
38
  if (message.type === "success" && message.id !== undefined) {
39
39
  const targetToResolve = this.resolveMap.get(message.id);
40
40
  if (targetToResolve) {
@@ -196,6 +196,37 @@ export class BiDiConnector {
196
196
  this.webSocket.close();
197
197
  }
198
198
  }
199
+ /** Channels whose messages carry what the tester typed (the recorder's) or adds as a secret (its toolbar's) */
200
+ const PRIVATE_CHANNEL = /^samurai-(recorder|toolbar)-/;
201
+ /**
202
+ * The message as the debug log gets it: a `script.message` on a private channel is logged by channel and
203
+ * action only, so a value the tester types or a secret added from the recorder toolbar never reaches the log
204
+ * (an added secret is only registered for masking after it is saved).
205
+ */
206
+ function forLog(message) {
207
+ const channel = message.params?.channel;
208
+ if (message.type !== "event" ||
209
+ message.method !== "script.message" ||
210
+ typeof channel !== "string" ||
211
+ !PRIVATE_CHANNEL.test(channel))
212
+ return message;
213
+ const entries = message.params?.data
214
+ ?.value;
215
+ const field = (key) => {
216
+ if (!Array.isArray(entries))
217
+ return undefined;
218
+ const entry = entries.find((pair) => Array.isArray(pair) && pair[0] === key);
219
+ const value = entry?.[1]?.value;
220
+ return typeof value === "string" ? value : undefined;
221
+ };
222
+ return {
223
+ ...message,
224
+ params: {
225
+ ...message.params,
226
+ data: { type: field("type"), action: field("action"), redacted: true },
227
+ },
228
+ };
229
+ }
199
230
  function pendingSuffix(pending) {
200
231
  return pending.length > 0 ? ` (pending: ${pending.join(", ")})` : "";
201
232
  }
@@ -35,6 +35,14 @@ export interface ScriptCommands {
35
35
  params: { handles: Handle[]; target: Target };
36
36
  result: DisownResult;
37
37
  };
38
+ /** Lists the realms that match, e.g. every window realm (sandboxes included) of a context. */
39
+ "script.getRealms": {
40
+ params: {
41
+ context?: BrowsingContext | undefined;
42
+ type?: RealmInfo["type"] | undefined;
43
+ };
44
+ result: GetRealmsResult;
45
+ };
38
46
  /** Registers a function to run before any page script in every matching new document. */
39
47
  "script.addPreloadScript": {
40
48
  params: {
@@ -54,13 +62,22 @@ export interface ScriptCommands {
54
62
  }
55
63
 
56
64
  /** A serialized value sent to the browser as a function argument or `this`. */
57
- export type LocalValue = { type: string; value?: unknown; [key: string]: unknown };
65
+ export type LocalValue = {
66
+ type: string;
67
+ value?: unknown;
68
+ [key: string]: unknown;
69
+ };
58
70
  /** A reference to a node or object that already lives in the browser. */
59
- export type RemoteReference = { sharedId: string; handle?: string | undefined } | { handle: string };
71
+ export type RemoteReference =
72
+ { sharedId: string; handle?: string | undefined } | { handle: string };
60
73
  /** A channel the browser can use to send messages back through `script.message`. */
61
74
  export interface ChannelValue {
62
75
  type: "channel";
63
- value: { channel: Channel; serializationOptions?: SerializationOptions; ownership?: ResultOwnership };
76
+ value: {
77
+ channel: Channel;
78
+ serializationOptions?: SerializationOptions;
79
+ ownership?: ResultOwnership;
80
+ };
64
81
  }
65
82
  export type ArgumentValue = LocalValue | RemoteReference | ChannelValue;
66
83
 
@@ -39,6 +39,8 @@ interface TestResultSuccess extends TestResultIdentity {
39
39
  logs?: TestLogEntry[];
40
40
  /** Entries that fell out of the page's log buffer */
41
41
  logsDropped?: number;
42
+ /** Named screenshots the test saved, relative to the result folder */
43
+ screenshots?: { name: string; path: string }[];
42
44
  }
43
45
 
44
46
  /** The error sits flat on the entry, as in the report: `message`, `type`, `stack`, and for assertions `expected` and `actual` */
@@ -47,6 +49,8 @@ interface TestResultFailed extends TestResultIdentity, TestError {
47
49
  duration: number;
48
50
  logs?: TestLogEntry[];
49
51
  logsDropped?: number;
52
+ /** Named screenshots the test saved, relative to the result folder */
53
+ screenshots?: { name: string; path: string }[];
50
54
  }
51
55
 
52
56
  /** One line of the browser's log, as stored in the report */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
5
5
  "main": "dist/api.js",
6
6
  "exports": {
@@ -44,6 +44,10 @@
44
44
  "types": "./dist/flows/steps/index.d.ts",
45
45
  "default": "./dist/flows/steps/index.js"
46
46
  },
47
+ "./project": {
48
+ "types": "./dist/project/index.d.ts",
49
+ "default": "./dist/project/index.js"
50
+ },
47
51
  "./package.json": "./package.json"
48
52
  },
49
53
  "scripts": {