@itmetsam/samurai-framework 0.6.0 → 0.7.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 +2 -2
- package/bin/samurai.mjs +25 -0
- package/dist/browser/evidence.d.ts +19 -0
- package/dist/browser/evidence.js +35 -0
- package/dist/browser/page.d.ts +6 -0
- package/dist/browser/page.js +20 -3
- package/dist/browser/screenshot.d.ts +2 -0
- package/dist/browser/screenshot.js +5 -2
- package/dist/cli/browsers.d.ts +1 -1
- package/dist/cli/browsers.js +2 -4
- package/dist/cli/main.js +15 -1
- package/dist/project/config-edit.d.ts +16 -0
- package/dist/project/config-edit.js +218 -0
- package/dist/project/index.d.ts +3 -0
- package/dist/project/index.js +3 -0
- package/dist/project/secrets-file.d.ts +8 -0
- package/dist/project/secrets-file.js +69 -0
- package/dist/project/variables.d.ts +6 -0
- package/dist/project/variables.js +17 -0
- package/dist/recorder/candidates.d.ts +7 -0
- package/dist/recorder/candidates.js +118 -0
- package/dist/recorder/capture.d.ts +8 -1
- package/dist/recorder/capture.js +25 -116
- package/dist/recorder/index.d.ts +1 -1
- package/dist/recorder/normalise.d.ts +28 -1
- package/dist/recorder/normalise.js +40 -1
- package/dist/recorder/recorder.d.ts +54 -0
- package/dist/recorder/recorder.js +195 -10
- package/dist/recorder/session.d.ts +15 -0
- package/dist/recorder/session.js +57 -7
- package/dist/recorder/toolbar/controller.d.ts +136 -0
- package/dist/recorder/toolbar/controller.js +471 -0
- package/dist/recorder/toolbar/inspect.d.ts +12 -0
- package/dist/recorder/toolbar/inspect.js +94 -0
- package/dist/recorder/toolbar/protocol.d.ts +135 -0
- package/dist/recorder/toolbar/protocol.js +4 -0
- package/dist/recorder/toolbar/source.d.ts +32 -0
- package/dist/recorder/toolbar/source.js +856 -0
- package/dist/recorder/toolbar/styles.d.ts +6 -0
- package/dist/recorder/toolbar/styles.js +95 -0
- package/dist/runner/reporter.d.ts +5 -1
- package/dist/runner/reporter.js +4 -3
- package/dist/runner/test-runner.d.ts +9 -0
- package/dist/runner/test-runner.js +39 -4
- package/dist/steps/emit.js +4 -0
- package/dist/steps/model.d.ts +11 -0
- package/dist/steps/parse.js +22 -3
- package/dist/transport/bidi-connection.js +32 -1
- package/dist/types/bidi-modules/script.d.ts +20 -3
- package/dist/types/test.d.ts +4 -0
- 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,
|
|
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
|
}
|
package/dist/runner/reporter.js
CHANGED
|
@@ -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,
|
|
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
|
-
...(
|
|
78
|
-
...(
|
|
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
|
-
|
|
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,
|
|
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)
|
package/dist/steps/emit.js
CHANGED
|
@@ -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
|
}
|
package/dist/steps/model.d.ts
CHANGED
|
@@ -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";
|
package/dist/steps/parse.js
CHANGED
|
@@ -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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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";
|
|
@@ -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 = {
|
|
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 =
|
|
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: {
|
|
76
|
+
value: {
|
|
77
|
+
channel: Channel;
|
|
78
|
+
serializationOptions?: SerializationOptions;
|
|
79
|
+
ownership?: ResultOwnership;
|
|
80
|
+
};
|
|
64
81
|
}
|
|
65
82
|
export type ArgumentValue = LocalValue | RemoteReference | ChannelValue;
|
|
66
83
|
|
package/dist/types/test.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.7.0",
|
|
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": {
|