@itmetsam/samurai-framework 0.5.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 +4 -4
- package/bin/samurai.mjs +25 -0
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/browser/browser-finder.d.ts +1 -0
- package/dist/browser/browser-finder.js +1 -0
- package/dist/browser/browser.d.ts +8 -0
- package/dist/browser/browser.js +33 -12
- 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/browser/warm-up.d.ts +14 -0
- package/dist/browser/warm-up.js +37 -0
- package/dist/browsers/cache-dir.d.ts +2 -0
- package/dist/browsers/cache-dir.js +15 -0
- package/dist/browsers/downloader.d.ts +23 -0
- package/dist/browsers/downloader.js +52 -0
- package/dist/browsers/index.d.ts +6 -0
- package/dist/browsers/index.js +5 -0
- package/dist/browsers/overrides.d.ts +17 -0
- package/dist/browsers/overrides.js +15 -0
- package/dist/browsers/pins.d.ts +16 -0
- package/dist/browsers/pins.js +14 -0
- package/dist/browsers/resolve.d.ts +26 -0
- package/dist/browsers/resolve.js +56 -0
- package/dist/browsers/store.d.ts +52 -0
- package/dist/browsers/store.js +170 -0
- package/dist/cli/args.d.ts +2 -0
- package/dist/cli/args.js +2 -0
- package/dist/cli/browsers.d.ts +6 -0
- package/dist/cli/browsers.js +117 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +26 -2
- 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 +61 -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 +44 -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.d.ts +2 -0
- package/dist/transport/bidi-connection.js +33 -2
- package/dist/types/bidi-modules/script.d.ts +20 -3
- package/dist/types/config.d.ts +5 -0
- package/dist/types/test.d.ts +4 -0
- package/package.json +10 -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";
|
|
@@ -3,6 +3,7 @@ import { glob } from "node:fs/promises";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { pathToFileURL } from "url";
|
|
5
5
|
import { Browser } from "../browser/browser.js";
|
|
6
|
+
import { ensureWarm } from "../browser/warm-up.js";
|
|
6
7
|
import { loadConfig, projectDir, readConfig } from "../config/config.js";
|
|
7
8
|
import logger from "../logger/index.js";
|
|
8
9
|
import { assertModuleProject } from "./module-type.js";
|
|
@@ -11,6 +12,7 @@ import { createEnvFixture } from "../config/variables.js";
|
|
|
11
12
|
import { createSecretsFixture } from "../config/secrets.js";
|
|
12
13
|
import TestReporter, {} from "./reporter.js";
|
|
13
14
|
import Page from "../browser/page.js";
|
|
15
|
+
import { cleanName, Evidence } from "../browser/evidence.js";
|
|
14
16
|
import { takePendingAssertions } from "../assert/expect.js";
|
|
15
17
|
import { toTestError } from "./test-error.js";
|
|
16
18
|
import { ConnectionClosedError } from "../transport/bidi-connection.js";
|
|
@@ -29,6 +31,8 @@ export function isSelected(name, { grep, testNames }) {
|
|
|
29
31
|
}
|
|
30
32
|
export default class TestRunner {
|
|
31
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 = "";
|
|
32
36
|
this.testFiles = testFiles;
|
|
33
37
|
this.run = run;
|
|
34
38
|
this.group = group;
|
|
@@ -81,9 +85,7 @@ export default class TestRunner {
|
|
|
81
85
|
/** The config's groups, resolved against the tests the specs register */
|
|
82
86
|
async resolveAll(tests) {
|
|
83
87
|
const config = await loadConfig();
|
|
84
|
-
|
|
85
|
-
const dir = realPath(projectDir());
|
|
86
|
-
const srcDir = realPath(path.resolve(dir, config.srcDir ?? DEFAULT_SRC_DIR));
|
|
88
|
+
const { dir, srcDir } = realDirs(config.srcDir);
|
|
87
89
|
const groups = resolveGroups(config, tests.map(({ file, name }) => ({
|
|
88
90
|
file: toPosix(path.relative(srcDir, file)),
|
|
89
91
|
name,
|
|
@@ -115,6 +117,11 @@ export default class TestRunner {
|
|
|
115
117
|
...(onEvent && { onEvent }),
|
|
116
118
|
});
|
|
117
119
|
const selected = await this.register();
|
|
120
|
+
this.srcDir = realDirs((await loadConfig()).srcDir).srcDir;
|
|
121
|
+
// A missing browser is one clear error before the run, not a failure per test; a fresh build's slow
|
|
122
|
+
// first start happens here, outside any test's timeout
|
|
123
|
+
if (selected.length > 0)
|
|
124
|
+
await ensureWarm((await readConfig("browser")) ?? "firefox", { port: this.options.port ?? 9223 }, signal);
|
|
118
125
|
reporter.onStart(selected.length);
|
|
119
126
|
for (const test of selected) {
|
|
120
127
|
if (signal?.aborted) {
|
|
@@ -131,11 +138,15 @@ export default class TestRunner {
|
|
|
131
138
|
reporter.onTestStart(test);
|
|
132
139
|
// A test ends once, whichever of finishing, failing, timing out, an abort or a closed browser comes first
|
|
133
140
|
let ended = false;
|
|
141
|
+
let evidence;
|
|
134
142
|
const finish = (error, logs) => {
|
|
135
143
|
if (ended)
|
|
136
144
|
return;
|
|
137
145
|
ended = true;
|
|
138
|
-
reporter.onTestEnd(test, error,
|
|
146
|
+
reporter.onTestEnd(test, error, {
|
|
147
|
+
...logs,
|
|
148
|
+
screenshots: evidence?.screenshots,
|
|
149
|
+
});
|
|
139
150
|
};
|
|
140
151
|
let browserClosed = false;
|
|
141
152
|
/** Set once the runner itself closes the browser */
|
|
@@ -143,6 +154,10 @@ export default class TestRunner {
|
|
|
143
154
|
const selectedBrowser = (await readConfig("browser")) ?? "firefox";
|
|
144
155
|
const { settings, secrets } = this.run;
|
|
145
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");
|
|
146
161
|
let browser;
|
|
147
162
|
let page;
|
|
148
163
|
let timeoutTimer;
|
|
@@ -190,6 +205,8 @@ export default class TestRunner {
|
|
|
190
205
|
resolve();
|
|
191
206
|
});
|
|
192
207
|
page = launched.page;
|
|
208
|
+
evidence = new Evidence(resultDir, testDirOf(test, this.srcDir));
|
|
209
|
+
launched.page.setEvidence(evidence);
|
|
193
210
|
const fixtures = {
|
|
194
211
|
page: launched.page,
|
|
195
212
|
browser: launched.browser,
|
|
@@ -236,6 +253,29 @@ export default class TestRunner {
|
|
|
236
253
|
await browser?.close();
|
|
237
254
|
}
|
|
238
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
|
+
}
|
|
239
279
|
/** Reads the page's log once the test body is done and applies config `logs` (see judgePageLogs) */
|
|
240
280
|
async function judgeLogs(page, testFailed) {
|
|
241
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";
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { BiDiCommands, BiDiEvents } from "../types/bidi.js";
|
|
2
2
|
import { type EventName, type Subscription, type SubscribeOptions } from "./subscriptions.js";
|
|
3
|
+
/** Time (ms) a command may wait for its reply before it fails with code "timeout" */
|
|
4
|
+
export declare const DEFAULT_COMMAND_TIMEOUT = 30000;
|
|
3
5
|
export interface ConnectorOptions {
|
|
4
6
|
/** @default 30000 */
|
|
5
7
|
commandTimeout?: number;
|
|
@@ -4,7 +4,7 @@ import { WaitTimeoutError } from "../wait/wait-until.js";
|
|
|
4
4
|
import { BiDiError } from "./bidi-error.js";
|
|
5
5
|
import { SubscriptionManager, } from "./subscriptions.js";
|
|
6
6
|
/** Time (ms) a command may wait for its reply before it fails with code "timeout" */
|
|
7
|
-
const DEFAULT_COMMAND_TIMEOUT = 30000;
|
|
7
|
+
export const DEFAULT_COMMAND_TIMEOUT = 30000;
|
|
8
8
|
/**
|
|
9
9
|
* The connection to the browser is gone (the browser exited or was closed, or the socket dropped). Commands that
|
|
10
10
|
* were waiting fail with it, and so does every command sent afterwards: a closed socket would swallow them silently.
|
|
@@ -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/config.d.ts
CHANGED
|
@@ -49,6 +49,11 @@ export interface SamuraiTestConfig {
|
|
|
49
49
|
* Is overwritten by the 'browser' in a group's config
|
|
50
50
|
*/
|
|
51
51
|
browser?: SupportedBrowser;
|
|
52
|
+
/**
|
|
53
|
+
* Browser binaries to launch instead of the builds SAMURAI downloads, relative to the project.
|
|
54
|
+
* `SAMURAI_FIREFOX_PATH` / `SAMURAI_CHROME_PATH` win over these.
|
|
55
|
+
*/
|
|
56
|
+
browserPaths?: { firefox?: string; chrome?: string };
|
|
52
57
|
/**
|
|
53
58
|
* The timeout (in ms) to use for all tests
|
|
54
59
|
* @default 30000
|
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": {
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"types": "./dist/cli/init.d.ts",
|
|
25
25
|
"default": "./dist/cli/init.js"
|
|
26
26
|
},
|
|
27
|
+
"./browsers": {
|
|
28
|
+
"types": "./dist/browsers/index.d.ts",
|
|
29
|
+
"default": "./dist/browsers/index.js"
|
|
30
|
+
},
|
|
27
31
|
"./browser": {
|
|
28
32
|
"types": "./dist/browser/index.d.ts",
|
|
29
33
|
"default": "./dist/browser/index.js"
|
|
@@ -40,6 +44,10 @@
|
|
|
40
44
|
"types": "./dist/flows/steps/index.d.ts",
|
|
41
45
|
"default": "./dist/flows/steps/index.js"
|
|
42
46
|
},
|
|
47
|
+
"./project": {
|
|
48
|
+
"types": "./dist/project/index.d.ts",
|
|
49
|
+
"default": "./dist/project/index.js"
|
|
50
|
+
},
|
|
43
51
|
"./package.json": "./package.json"
|
|
44
52
|
},
|
|
45
53
|
"scripts": {
|
|
@@ -65,6 +73,7 @@
|
|
|
65
73
|
"tsx": "^4.21.0"
|
|
66
74
|
},
|
|
67
75
|
"dependencies": {
|
|
76
|
+
"@puppeteer/browsers": "^3.2.4",
|
|
68
77
|
"typescript": "^6.0.2",
|
|
69
78
|
"winston": "^3.19.0"
|
|
70
79
|
},
|