@itmetsam/samurai-framework 0.1.0 → 0.2.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 +129 -0
- package/bin/samurai.mjs +31 -0
- package/dist/api.js +3 -0
- package/dist/assert/assertion-error.d.ts +23 -0
- package/dist/assert/assertion-error.js +45 -0
- package/dist/assert/expect.d.ts +55 -0
- package/dist/assert/expect.js +188 -0
- package/dist/browser/browser-context.d.ts +91 -0
- package/dist/browser/browser-context.js +146 -0
- package/dist/browser/browser-finder.d.ts +2 -0
- package/dist/browser/browser-finder.js +95 -0
- package/dist/browser/browser.d.ts +95 -0
- package/dist/browser/browser.js +372 -0
- package/dist/browser/context-tree.d.ts +23 -0
- package/dist/browser/context-tree.js +60 -0
- package/dist/browser/dialog.d.ts +37 -0
- package/dist/browser/dialog.js +61 -0
- package/dist/browser/download.d.ts +71 -0
- package/dist/browser/download.js +137 -0
- package/dist/browser/downloads-dir.d.ts +8 -0
- package/dist/browser/downloads-dir.js +38 -0
- package/dist/browser/emulation.d.ts +61 -0
- package/dist/browser/emulation.js +144 -0
- package/dist/browser/file-chooser.d.ts +39 -0
- package/dist/browser/file-chooser.js +86 -0
- package/dist/browser/file-paths.d.ts +4 -0
- package/dist/browser/file-paths.js +18 -0
- package/dist/browser/index.d.ts +4 -0
- package/dist/browser/index.js +4 -0
- package/dist/browser/navigation-error.d.ts +13 -0
- package/dist/browser/navigation-error.js +19 -0
- package/dist/browser/page-logs.d.ts +86 -0
- package/dist/browser/page-logs.js +149 -0
- package/dist/browser/page-wait.d.ts +9 -0
- package/dist/browser/page-wait.js +17 -0
- package/dist/browser/page.d.ts +370 -0
- package/dist/browser/page.js +1038 -0
- package/dist/browser/pdf.d.ts +72 -0
- package/dist/browser/pdf.js +37 -0
- package/dist/browser/screenshot.d.ts +29 -0
- package/dist/browser/screenshot.js +42 -0
- package/dist/cli/args.d.ts +35 -0
- package/dist/cli/args.js +69 -0
- package/dist/cli/cli.cli-test.d.ts +1 -0
- package/dist/cli/cli.cli-test.js +170 -0
- package/dist/cli/console-reporter.d.ts +15 -0
- package/dist/cli/console-reporter.js +52 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +61 -0
- package/dist/cli/main.d.ts +12 -0
- package/dist/cli/main.js +156 -0
- package/dist/config/config.d.ts +24 -0
- package/dist/config/config.js +42 -0
- package/dist/config/default.d.ts +2 -0
- package/dist/config/default.js +3 -0
- package/dist/config/mask.d.ts +11 -0
- package/dist/config/mask.js +85 -0
- package/dist/config/run-settings.d.ts +28 -0
- package/dist/config/run-settings.js +61 -0
- package/dist/config/secrets.d.ts +10 -0
- package/dist/config/secrets.js +67 -0
- package/dist/config/variables.d.ts +11 -0
- package/dist/config/variables.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -0
- package/dist/locator/action-timeout-error.d.ts +25 -0
- package/dist/locator/action-timeout-error.js +35 -0
- package/dist/locator/element-state.d.ts +44 -0
- package/dist/locator/element-state.js +109 -0
- package/dist/locator/label-locator.d.ts +13 -0
- package/dist/locator/label-locator.js +49 -0
- package/dist/locator/locator.d.ts +168 -0
- package/dist/locator/locator.js +591 -0
- package/dist/locator/selector-errors.d.ts +9 -0
- package/dist/locator/selector-errors.js +15 -0
- package/dist/locator/selector.d.ts +60 -0
- package/dist/locator/selector.js +113 -0
- package/dist/locator/text-locator.d.ts +13 -0
- package/dist/logger/index.d.ts +2 -0
- package/dist/logger/index.js +30 -0
- package/dist/network/data-collector.d.ts +26 -0
- package/dist/network/data-collector.js +77 -0
- package/dist/network/network-tracker.d.ts +94 -0
- package/dist/network/network-tracker.js +198 -0
- package/dist/network/network-wait.d.ts +16 -0
- package/dist/network/network-wait.js +24 -0
- package/dist/network/response.d.ts +22 -0
- package/dist/network/response.js +42 -0
- package/dist/network/router.d.ts +120 -0
- package/dist/network/router.js +343 -0
- package/dist/network/url-match.d.ts +8 -0
- package/dist/network/url-match.js +38 -0
- package/dist/recorder/apply.d.ts +3 -0
- package/dist/recorder/apply.js +10 -0
- package/dist/recorder/capture.d.ts +18 -0
- package/dist/recorder/capture.js +198 -0
- package/dist/recorder/index.d.ts +3 -0
- package/dist/recorder/index.js +3 -0
- package/dist/recorder/normalise.d.ts +68 -0
- package/dist/recorder/normalise.js +104 -0
- package/dist/recorder/ranking.d.ts +9 -0
- package/dist/recorder/ranking.js +21 -0
- package/dist/recorder/recorder.d.ts +52 -0
- package/dist/recorder/recorder.js +143 -0
- package/dist/recorder/session.d.ts +49 -0
- package/dist/recorder/session.js +113 -0
- package/dist/runner/module-type.d.ts +6 -0
- package/dist/runner/module-type.js +29 -0
- package/dist/runner/page-logs-report.d.ts +40 -0
- package/dist/runner/page-logs-report.js +44 -0
- package/dist/runner/prepare-run.d.ts +14 -0
- package/dist/runner/prepare-run.js +20 -0
- package/dist/runner/project.d.ts +17 -0
- package/dist/runner/project.js +33 -0
- package/dist/runner/registry.d.ts +7 -0
- package/dist/runner/registry.js +40 -0
- package/dist/runner/reporter.d.ts +43 -0
- package/dist/runner/reporter.js +83 -0
- package/dist/runner/run.d.ts +27 -0
- package/dist/runner/run.js +34 -0
- package/dist/runner/test-error.d.ts +3 -0
- package/dist/runner/test-error.js +18 -0
- package/dist/runner/test-runner.d.ts +44 -0
- package/dist/runner/test-runner.js +205 -0
- package/dist/script/call-function.d.ts +34 -0
- package/dist/script/call-function.js +71 -0
- package/dist/script/element-handle.d.ts +10 -0
- package/{src/script/element-handle.ts → dist/script/element-handle.js} +4 -4
- package/dist/script/helpers.d.ts +28 -0
- package/dist/script/helpers.js +50 -0
- package/dist/script/preload.d.ts +31 -0
- package/dist/script/preload.js +71 -0
- package/dist/script/serialize.d.ts +29 -0
- package/dist/script/serialize.js +207 -0
- package/dist/steps/build.d.ts +5 -0
- package/dist/steps/build.js +38 -0
- package/dist/steps/edit.d.ts +30 -0
- package/dist/steps/edit.js +80 -0
- package/dist/steps/emit.d.ts +6 -0
- package/dist/steps/emit.js +73 -0
- package/dist/steps/index.d.ts +5 -0
- package/dist/steps/index.js +4 -0
- package/dist/steps/model.d.ts +103 -0
- package/dist/steps/model.js +1 -0
- package/dist/steps/parse.d.ts +3 -0
- package/dist/steps/parse.js +272 -0
- package/dist/transport/bidi-connection.d.ts +46 -0
- package/dist/transport/bidi-connection.js +176 -0
- package/dist/transport/bidi-error.d.ts +10 -0
- package/dist/transport/bidi-error.js +10 -0
- package/dist/transport/subscriptions.d.ts +36 -0
- package/dist/transport/subscriptions.js +81 -0
- package/{src → dist}/types/test.d.ts +10 -4
- package/dist/wait/wait-until.d.ts +20 -0
- package/dist/wait/wait-until.js +63 -0
- package/package.json +44 -7
- package/.claude/settings.local.json +0 -7
- package/.env.example +0 -3
- package/.github/actions/setup/action.yml +0 -16
- package/.github/workflows/ci.yml +0 -71
- package/.trunk/configs/.markdownlint.yaml +0 -2
- package/.trunk/configs/.yamllint.yaml +0 -7
- package/.trunk/configs/svgo.config.mjs +0 -14
- package/.trunk/trunk.yaml +0 -34
- package/docs/superpowers/bidi-roadmap.md +0 -60
- package/docs/superpowers/plans/2026-09-29-auto-wait.md +0 -1827
- package/docs/superpowers/plans/2026-09-29-expect.md +0 -1350
- package/docs/superpowers/plans/2026-10-01-ci.md +0 -350
- package/docs/superpowers/plans/2026-10-01-test-api-alignment.md +0 -1821
- package/docs/superpowers/recorder-roadmap.md +0 -52
- package/docs/superpowers/specs/2026-09-29-auto-wait-design.md +0 -185
- package/docs/superpowers/specs/2026-09-29-bidi-foundation-design.md +0 -130
- package/docs/superpowers/specs/2026-09-29-contexts-and-storage-design.md +0 -88
- package/docs/superpowers/specs/2026-09-29-downloads-and-file-dialogs-design.md +0 -94
- package/docs/superpowers/specs/2026-09-29-emulation-design.md +0 -88
- package/docs/superpowers/specs/2026-09-29-expect-design.md +0 -130
- package/docs/superpowers/specs/2026-09-29-locate-nodes-design.md +0 -90
- package/docs/superpowers/specs/2026-09-29-logs-and-errors-design.md +0 -87
- package/docs/superpowers/specs/2026-09-29-navigation-waits-design.md +0 -83
- package/docs/superpowers/specs/2026-09-29-network-data-design.md +0 -78
- package/docs/superpowers/specs/2026-09-29-network-interception-design.md +0 -91
- package/docs/superpowers/specs/2026-09-29-network-tracking-design.md +0 -86
- package/docs/superpowers/specs/2026-09-29-page-api-design.md +0 -90
- package/docs/superpowers/specs/2026-09-29-preload-scripts-design.md +0 -82
- package/docs/superpowers/specs/2026-09-29-script-call-function-design.md +0 -107
- package/docs/superpowers/specs/2026-09-29-set-files-design.md +0 -62
- package/docs/superpowers/specs/2026-09-29-user-prompts-design.md +0 -75
- package/docs/superpowers/specs/2026-10-01-ci-design.md +0 -106
- package/docs/superpowers/specs/2026-10-01-test-api-alignment-design.md +0 -241
- package/public/logo-dark.svg +0 -236
- package/public/logo-light.svg +0 -262
- package/samurai.config.ts +0 -7
- package/src/assert/assertion-error.test.ts +0 -55
- package/src/assert/assertion-error.ts +0 -62
- package/src/assert/expect-locator.test.ts +0 -217
- package/src/assert/expect.test.ts +0 -73
- package/src/assert/expect.ts +0 -299
- package/src/browser/browser-context.browser-test.ts +0 -201
- package/src/browser/browser-context.test.ts +0 -407
- package/src/browser/browser-context.ts +0 -208
- package/src/browser/browser-finder.ts +0 -110
- package/src/browser/browser.test.ts +0 -88
- package/src/browser/browser.ts +0 -515
- package/src/browser/context-tree.test.ts +0 -86
- package/src/browser/context-tree.ts +0 -70
- package/src/browser/dialog.browser-test.ts +0 -144
- package/src/browser/dialog.test.ts +0 -322
- package/src/browser/dialog.ts +0 -83
- package/src/browser/download.test.ts +0 -161
- package/src/browser/download.ts +0 -172
- package/src/browser/downloads-dir.test.ts +0 -75
- package/src/browser/downloads-dir.ts +0 -39
- package/src/browser/emulation.browser-test.ts +0 -186
- package/src/browser/emulation.test.ts +0 -200
- package/src/browser/emulation.ts +0 -204
- package/src/browser/file-chooser.test.ts +0 -142
- package/src/browser/file-chooser.ts +0 -99
- package/src/browser/file-paths.ts +0 -18
- package/src/browser/goto.browser-test.ts +0 -44
- package/src/browser/navigation-error.ts +0 -26
- package/src/browser/navigation.browser-test.ts +0 -172
- package/src/browser/page-api.browser-test.ts +0 -228
- package/src/browser/page-logs.browser-test.ts +0 -119
- package/src/browser/page-logs.test.ts +0 -212
- package/src/browser/page-logs.ts +0 -208
- package/src/browser/page-wait.ts +0 -19
- package/src/browser/page.test.ts +0 -1567
- package/src/browser/page.ts +0 -1176
- package/src/browser/pdf.ts +0 -62
- package/src/browser/screenshot.ts +0 -65
- package/src/browser/transfers.browser-test.ts +0 -180
- package/src/config/config.ts +0 -33
- package/src/config/default.ts +0 -5
- package/src/config/fixtures.test.ts +0 -137
- package/src/config/mask.test.ts +0 -143
- package/src/config/mask.ts +0 -87
- package/src/config/run-settings.test.ts +0 -163
- package/src/config/run-settings.ts +0 -114
- package/src/config/secrets.ts +0 -70
- package/src/config/variables.ts +0 -31
- package/src/index.ts +0 -10
- package/src/locator/action-timeout-error.test.ts +0 -83
- package/src/locator/action-timeout-error.ts +0 -72
- package/src/locator/element-state.test.ts +0 -181
- package/src/locator/element-state.ts +0 -159
- package/src/locator/locate-nodes.browser-test.ts +0 -169
- package/src/locator/locator-actions.test.ts +0 -355
- package/src/locator/locator-chain.test.ts +0 -292
- package/src/locator/locator.browser-test.ts +0 -137
- package/src/locator/locator.test.ts +0 -179
- package/src/locator/locator.ts +0 -656
- package/src/locator/selector-errors.ts +0 -18
- package/src/locator/selector.test.ts +0 -124
- package/src/locator/selector.ts +0 -107
- package/src/locator/set-files.browser-test.ts +0 -141
- package/src/locator/set-files.test.ts +0 -191
- package/src/locator/text-locator.test.ts +0 -118
- package/src/logger/index.test.ts +0 -28
- package/src/logger/index.ts +0 -40
- package/src/network/data-collector.test.ts +0 -111
- package/src/network/data-collector.ts +0 -92
- package/src/network/network-data.browser-test.ts +0 -192
- package/src/network/network-tracker.browser-test.ts +0 -151
- package/src/network/network-tracker.test.ts +0 -307
- package/src/network/network-tracker.ts +0 -286
- package/src/network/network-wait.ts +0 -28
- package/src/network/response.test.ts +0 -93
- package/src/network/response.ts +0 -58
- package/src/network/router.browser-test.ts +0 -260
- package/src/network/router.test.ts +0 -528
- package/src/network/router.ts +0 -420
- package/src/network/url-match.test.ts +0 -69
- package/src/network/url-match.ts +0 -41
- package/src/runner/page-logs-report.test.ts +0 -144
- package/src/runner/page-logs-report.ts +0 -82
- package/src/runner/prepare-run.test.ts +0 -33
- package/src/runner/prepare-run.ts +0 -36
- package/src/runner/registry.test.ts +0 -59
- package/src/runner/registry.ts +0 -46
- package/src/runner/reporter.test.ts +0 -73
- package/src/runner/reporter.ts +0 -109
- package/src/runner/test-error.test.ts +0 -39
- package/src/runner/test-error.ts +0 -20
- package/src/runner/test-runner.ts +0 -184
- package/src/script/call-function.browser-test.ts +0 -112
- package/src/script/call-function.test.ts +0 -174
- package/src/script/call-function.ts +0 -113
- package/src/script/helpers.test.ts +0 -116
- package/src/script/helpers.ts +0 -68
- package/src/script/preload.browser-test.ts +0 -144
- package/src/script/preload.test.ts +0 -180
- package/src/script/preload.ts +0 -102
- package/src/script/serialize.test.ts +0 -182
- package/src/script/serialize.ts +0 -203
- package/src/testing/browser-fixture.ts +0 -18
- package/src/testing/fake-websocket.ts +0 -72
- package/src/testing/stub-connector.ts +0 -154
- package/src/tests/index.spec.ts +0 -30
- package/src/transport/bidi-connection.test.ts +0 -155
- package/src/transport/bidi-connection.ts +0 -256
- package/src/transport/bidi-error.ts +0 -23
- package/src/transport/foundation.browser-test.ts +0 -73
- package/src/transport/subscriptions.test.ts +0 -140
- package/src/transport/subscriptions.ts +0 -110
- package/src/types/bidi-commands.test-d.ts +0 -55
- package/src/wait/wait-until.test.ts +0 -117
- package/src/wait/wait-until.ts +0 -86
- package/tsconfig.json +0 -44
- /package/{src/api.ts → dist/api.d.ts} +0 -0
- /package/{src/locator/text-locator.ts → dist/locator/text-locator.js} +0 -0
- /package/{src → dist}/types/bidi-modules/browser.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/browsing-context.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/emulation.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/input.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/log.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/network.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/permissions.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/script.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/session.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/storage.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/web-extensions.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/command.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/error.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/event.d.ts +0 -0
- /package/{src → dist}/types/bidi.d.ts +0 -0
- /package/{src → dist}/types/browser.d.ts +0 -0
- /package/{src → dist}/types/config.d.ts +0 -0
- /package/{src → dist}/types/env.d.ts +0 -0
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parseCommandLine, parseRunnerFlags, parseRunOverrides } from "./args.js";
|
|
3
|
+
import { recordSpec } from "../recorder/index.js";
|
|
4
|
+
import { listTests, runTests } from "../runner/run.js";
|
|
5
|
+
import { stepToSource } from "../steps/index.js";
|
|
6
|
+
import { ConsoleReporter } from "./console-reporter.js";
|
|
7
|
+
import { initProject } from "./init.js";
|
|
8
|
+
export const USAGE = `samurai: browser tests over WebDriver BiDi
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
samurai run [options] Run the tests (the default command)
|
|
12
|
+
samurai list [options] List the tests without running them
|
|
13
|
+
samurai record <spec> Open a browser and record what you do into a spec file
|
|
14
|
+
samurai init [folder] Scaffold a project: config, an example test, .env.example
|
|
15
|
+
|
|
16
|
+
Options for run and list:
|
|
17
|
+
--env <name> Environment to run against (or SAMURAI_ENV)
|
|
18
|
+
--file <spec> Only this spec file; repeatable
|
|
19
|
+
--grep <text> Only tests whose full name contains the text
|
|
20
|
+
--json Machine-readable output: JSON lines for run, one array for list
|
|
21
|
+
|
|
22
|
+
Options for run:
|
|
23
|
+
--timeout <ms> Time one test may take
|
|
24
|
+
--expect-timeout <ms> Time actions and assertions retry
|
|
25
|
+
--headless Run the browser without a window
|
|
26
|
+
--port <n> Browser debugging port (default 9223)
|
|
27
|
+
|
|
28
|
+
Options for record (also --env, --port, --json):
|
|
29
|
+
--new <title> Add a test with this title (creates the spec file if needed)
|
|
30
|
+
--test <name|index> Record into this test; not needed when the file has one
|
|
31
|
+
--at <n> Insert from step n (default: after the last step)
|
|
32
|
+
--url <url> Page to start on (default: the environment's baseURL)
|
|
33
|
+
Stop with Ctrl+C or by closing the browser window.
|
|
34
|
+
|
|
35
|
+
-h, --help Show this help
|
|
36
|
+
-v, --version Show the version
|
|
37
|
+
|
|
38
|
+
Exit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.
|
|
39
|
+
Run it in the project folder, the one holding samurai.config.ts.
|
|
40
|
+
`;
|
|
41
|
+
function version() {
|
|
42
|
+
const manifest = new URL("../../package.json", import.meta.url);
|
|
43
|
+
return JSON.parse(readFileSync(manifest, "utf8"))
|
|
44
|
+
.version;
|
|
45
|
+
}
|
|
46
|
+
/** The command line as `samurai` runs it. Returns the exit code */
|
|
47
|
+
export async function main(argv, io) {
|
|
48
|
+
try {
|
|
49
|
+
const first = argv[0];
|
|
50
|
+
const command = first !== undefined && !first.startsWith("-") ? first : "run";
|
|
51
|
+
const rest = command === first ? argv.slice(1) : argv;
|
|
52
|
+
const { values, positionals } = parseCommandLine(rest);
|
|
53
|
+
if (values.help || command === "help") {
|
|
54
|
+
io.out(USAGE);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (values.version) {
|
|
58
|
+
io.out(`${version()}\n`);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
if (command === "init") {
|
|
62
|
+
const { written, skipped } = initProject(positionals[0] ?? ".");
|
|
63
|
+
for (const file of written)
|
|
64
|
+
io.out(`created ${file}\n`);
|
|
65
|
+
for (const file of skipped)
|
|
66
|
+
io.out(`kept ${file} (already there)\n`);
|
|
67
|
+
io.out("\nNext: install the dependencies, then `samurai run`.\n");
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
if (command === "record") {
|
|
71
|
+
const file = positionals[0];
|
|
72
|
+
if (!file)
|
|
73
|
+
throw new Error("Which spec? Usage: samurai record <spec> [--new <title>]");
|
|
74
|
+
const at = values.at === undefined ? undefined : Number(values.at);
|
|
75
|
+
if (at !== undefined && (!Number.isInteger(at) || at < 0))
|
|
76
|
+
throw new Error(`--at must be a step number, got "${values.at}"`);
|
|
77
|
+
const flags = parseRunnerFlags(rest);
|
|
78
|
+
const test = values.test === undefined
|
|
79
|
+
? undefined
|
|
80
|
+
: /^\d+$/.test(values.test)
|
|
81
|
+
? Number(values.test)
|
|
82
|
+
: values.test;
|
|
83
|
+
let count = 0;
|
|
84
|
+
const result = await recordSpec({
|
|
85
|
+
...parseRunOverrides(rest, io.env),
|
|
86
|
+
file,
|
|
87
|
+
...(test !== undefined && { test }),
|
|
88
|
+
...(values.new !== undefined && { create: values.new }),
|
|
89
|
+
...(at !== undefined && { at }),
|
|
90
|
+
...(values.url !== undefined && { url: values.url }),
|
|
91
|
+
...(flags.port !== undefined && { port: flags.port }),
|
|
92
|
+
...(io.signal && { signal: io.signal }),
|
|
93
|
+
onStarted: () => {
|
|
94
|
+
if (!values.json)
|
|
95
|
+
io.out("Recording. Alt+click an element to assert on it. Press Ctrl+C or close the window to stop.\n");
|
|
96
|
+
},
|
|
97
|
+
onEvent: (event) => {
|
|
98
|
+
if (values.json)
|
|
99
|
+
return void io.out(`${JSON.stringify(event)}\n`);
|
|
100
|
+
if (event.op === "insert")
|
|
101
|
+
io.out(` + ${stepToSource(event.step)}\n`);
|
|
102
|
+
else
|
|
103
|
+
io.out(` ~ ${stepToSource(event.step)}\n`);
|
|
104
|
+
if (event.op === "insert")
|
|
105
|
+
count++;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
if (!values.json) {
|
|
109
|
+
io.out(`\nRecorded ${count} ${count === 1 ? "step" : "steps"} into "${result.test}" in ${result.file}\n`);
|
|
110
|
+
if (result.secrets.length > 0) {
|
|
111
|
+
io.out(`Set ${result.secrets.map((name) => `SAMURAI_SECRET_${name}`).join(", ")} (environment variable or .env.<environment>) before running it.\n`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
if (command !== "run" && command !== "list") {
|
|
117
|
+
io.err(`Unknown command "${command}"\n\n${USAGE}`);
|
|
118
|
+
return 2;
|
|
119
|
+
}
|
|
120
|
+
const flags = parseRunnerFlags(rest);
|
|
121
|
+
const options = {
|
|
122
|
+
...parseRunOverrides(rest, io.env),
|
|
123
|
+
...(flags.headless !== undefined && { headless: flags.headless }),
|
|
124
|
+
...(flags.port !== undefined && { port: flags.port }),
|
|
125
|
+
...(flags.grep !== undefined && { grep: flags.grep }),
|
|
126
|
+
...(flags.files && { files: flags.files }),
|
|
127
|
+
};
|
|
128
|
+
if (command === "list") {
|
|
129
|
+
const tests = await listTests(options);
|
|
130
|
+
if (values.json)
|
|
131
|
+
io.out(`${JSON.stringify(tests, null, 2)}\n`);
|
|
132
|
+
else
|
|
133
|
+
for (const found of tests)
|
|
134
|
+
io.out(`${found.name}\n ${found.file}\n`);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
const human = values.json
|
|
138
|
+
? undefined
|
|
139
|
+
: new ConsoleReporter({ write: io.out, color: io.color });
|
|
140
|
+
const summary = await runTests({
|
|
141
|
+
...options,
|
|
142
|
+
...(io.signal && { signal: io.signal }),
|
|
143
|
+
onEvent: (event) => {
|
|
144
|
+
if (values.json)
|
|
145
|
+
io.out(`${JSON.stringify(event)}\n`);
|
|
146
|
+
else
|
|
147
|
+
human?.handle(event);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
return summary.status === "success" ? 0 : 1;
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
io.err(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
154
|
+
return 2;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SamuraiTestConfig } from "../types/config.js";
|
|
2
|
+
/** The project a run belongs to; without one, everything is relative to the working directory */
|
|
3
|
+
interface ActiveProject {
|
|
4
|
+
dir: string;
|
|
5
|
+
/** Used instead of reading `samurai.config.ts` */
|
|
6
|
+
config?: SamuraiTestConfig | undefined;
|
|
7
|
+
/** Where the framework keeps browser profiles; see `browsersDir` */
|
|
8
|
+
dataDir?: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
/** Makes `project` the one config, secrets, reports and downloads are resolved for; `undefined` ends it */
|
|
11
|
+
export declare function setActiveProject(project: ActiveProject | undefined): void;
|
|
12
|
+
/** The folder of the project being run: the active project's, else the working directory */
|
|
13
|
+
export declare function projectDir(): string;
|
|
14
|
+
/**
|
|
15
|
+
* Where the framework keeps what it generates for browsers (profile preferences, Firefox app data):
|
|
16
|
+
* `SAMURAI_DATA_DIR`, else the active project's `dataDir`, else `browsers/` in the project folder.
|
|
17
|
+
* An embedding app points it at a folder it may write to.
|
|
18
|
+
*/
|
|
19
|
+
export declare function browsersDir(): string;
|
|
20
|
+
/** The whole `samurai.config.ts` of the project (the working directory unless a project is active) */
|
|
21
|
+
export declare function loadConfig(): Promise<SamuraiTestConfig>;
|
|
22
|
+
export declare function readConfig<K extends keyof SamuraiTestConfig>(key: K): Promise<SamuraiTestConfig[K]>;
|
|
23
|
+
export declare function defineConfig(config: SamuraiTestConfig): SamuraiTestConfig;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import logger from "../logger/index.js";
|
|
3
|
+
import { pathToFileURL } from "url";
|
|
4
|
+
let activeProject;
|
|
5
|
+
/** Makes `project` the one config, secrets, reports and downloads are resolved for; `undefined` ends it */
|
|
6
|
+
export function setActiveProject(project) {
|
|
7
|
+
activeProject = project && { ...project, dir: path.resolve(project.dir) };
|
|
8
|
+
}
|
|
9
|
+
/** The folder of the project being run: the active project's, else the working directory */
|
|
10
|
+
export function projectDir() {
|
|
11
|
+
return activeProject?.dir ?? process.cwd();
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Where the framework keeps what it generates for browsers (profile preferences, Firefox app data):
|
|
15
|
+
* `SAMURAI_DATA_DIR`, else the active project's `dataDir`, else `browsers/` in the project folder.
|
|
16
|
+
* An embedding app points it at a folder it may write to.
|
|
17
|
+
*/
|
|
18
|
+
export function browsersDir() {
|
|
19
|
+
return path.resolve(process.env.SAMURAI_DATA_DIR ??
|
|
20
|
+
activeProject?.dataDir ??
|
|
21
|
+
path.join(projectDir(), "browsers"));
|
|
22
|
+
}
|
|
23
|
+
/** The whole `samurai.config.ts` of the project (the working directory unless a project is active) */
|
|
24
|
+
export async function loadConfig() {
|
|
25
|
+
if (activeProject?.config)
|
|
26
|
+
return activeProject.config;
|
|
27
|
+
const configUrl = path.join(projectDir(), "samurai.config.ts");
|
|
28
|
+
logger.debug("Reading config from: %s", configUrl);
|
|
29
|
+
const config = (await import(pathToFileURL(configUrl).href));
|
|
30
|
+
if (!config.default) {
|
|
31
|
+
throw new Error("samurai.config.ts does not export defineConfig as default!");
|
|
32
|
+
}
|
|
33
|
+
return config.default;
|
|
34
|
+
}
|
|
35
|
+
export async function readConfig(key) {
|
|
36
|
+
const value = (await loadConfig())[key];
|
|
37
|
+
logger.debug("Resolved config value: ", { value });
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
export function defineConfig(config) {
|
|
41
|
+
return config;
|
|
42
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const MASK = "\u2022\u2022\u2022\u2022";
|
|
2
|
+
/** Registers a secret value; every later maskText/maskDeep/log line hides it */
|
|
3
|
+
export declare function registerSecret(name: string, value: string): void;
|
|
4
|
+
/** Forgets every registered secret (tests only) */
|
|
5
|
+
export declare function resetSecrets(): void;
|
|
6
|
+
export declare function isShortSecret(value: string): boolean;
|
|
7
|
+
export declare function maskText(text: string): string;
|
|
8
|
+
/** A copy of `value` with every string in it masked, following JSON.stringify semantics for objects */
|
|
9
|
+
export declare function maskDeep<T>(value: T): T;
|
|
10
|
+
/** Winston format that masks every string field of a log entry (message and metadata) */
|
|
11
|
+
export declare const maskFormat: import("logform").FormatWrap;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { inspect } from "node:util";
|
|
2
|
+
import { format } from "winston";
|
|
3
|
+
export const MASK = "••••";
|
|
4
|
+
/** Values shorter than this only match as a whole token, so "1" does not hide every digit */
|
|
5
|
+
const SHORT_LENGTH = 4;
|
|
6
|
+
const secrets = new Map();
|
|
7
|
+
let patterns = [];
|
|
8
|
+
/** Registers a secret value; every later maskText/maskDeep/log line hides it */
|
|
9
|
+
export function registerSecret(name, value) {
|
|
10
|
+
if (value === "")
|
|
11
|
+
return;
|
|
12
|
+
secrets.set(name, value);
|
|
13
|
+
rebuild();
|
|
14
|
+
}
|
|
15
|
+
/** Forgets every registered secret (tests only) */
|
|
16
|
+
export function resetSecrets() {
|
|
17
|
+
secrets.clear();
|
|
18
|
+
patterns = [];
|
|
19
|
+
}
|
|
20
|
+
export function isShortSecret(value) {
|
|
21
|
+
return value.length > 0 && value.length < SHORT_LENGTH;
|
|
22
|
+
}
|
|
23
|
+
function escapeRegExp(value) {
|
|
24
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
}
|
|
26
|
+
/** A value plus its JSON-escaped, inspect-escaped and percent-encoded spellings */
|
|
27
|
+
function variants(value) {
|
|
28
|
+
const forms = [value, JSON.stringify(value).slice(1, -1), inspect(value).slice(1, -1), encodeURIComponent(value)];
|
|
29
|
+
return forms.filter((form) => form !== "");
|
|
30
|
+
}
|
|
31
|
+
function rebuild() {
|
|
32
|
+
// Longest first, so a secret that contains another one is hidden whole
|
|
33
|
+
const forms = [...new Set([...secrets.values()].flatMap(variants))].sort((a, b) => b.length - a.length);
|
|
34
|
+
patterns = forms.map((value) => isShortSecret(value)
|
|
35
|
+
? new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(value)}(?![\\p{L}\\p{N}])`, "gu")
|
|
36
|
+
: new RegExp(escapeRegExp(value), "gu"));
|
|
37
|
+
}
|
|
38
|
+
export function maskText(text) {
|
|
39
|
+
let masked = text;
|
|
40
|
+
for (const pattern of patterns)
|
|
41
|
+
masked = masked.replace(pattern, MASK);
|
|
42
|
+
return masked;
|
|
43
|
+
}
|
|
44
|
+
/** A copy of `value` with every string in it masked, following JSON.stringify semantics for objects */
|
|
45
|
+
export function maskDeep(value) {
|
|
46
|
+
if (patterns.length === 0)
|
|
47
|
+
return value;
|
|
48
|
+
return walk(value, new WeakSet());
|
|
49
|
+
}
|
|
50
|
+
function walk(value, path) {
|
|
51
|
+
if (typeof value === "string")
|
|
52
|
+
return maskText(value);
|
|
53
|
+
if (value === null || typeof value !== "object")
|
|
54
|
+
return value;
|
|
55
|
+
if (path.has(value))
|
|
56
|
+
return "[Circular]";
|
|
57
|
+
path.add(value);
|
|
58
|
+
try {
|
|
59
|
+
if (Array.isArray(value))
|
|
60
|
+
return value.map((item) => walk(item, path));
|
|
61
|
+
const toJSON = value.toJSON;
|
|
62
|
+
if (typeof toJSON === "function")
|
|
63
|
+
return walk(toJSON.call(value), path);
|
|
64
|
+
const entries = Object.entries(value);
|
|
65
|
+
if (value instanceof Error) {
|
|
66
|
+
const own = new Set(entries.map(([key]) => key));
|
|
67
|
+
for (const key of ["name", "message", "stack"]) {
|
|
68
|
+
if (!own.has(key))
|
|
69
|
+
entries.unshift([key, value[key]]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return Object.fromEntries(entries.map(([key, item]) => [key, walk(item, path)]));
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
path.delete(value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Winston format that masks every string field of a log entry (message and metadata) */
|
|
79
|
+
export const maskFormat = format((info) => {
|
|
80
|
+
if (patterns.length === 0)
|
|
81
|
+
return info;
|
|
82
|
+
for (const key of Object.keys(info))
|
|
83
|
+
info[key] = walk(info[key], new WeakSet());
|
|
84
|
+
return info;
|
|
85
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SamuraiTestConfig } from "../types/config.js";
|
|
2
|
+
export type Variables = Record<string, string | number | boolean>;
|
|
3
|
+
/** Values given for one run (CLI flags, SAMURAI_ENV); they beat the config */
|
|
4
|
+
export interface RunOverrides {
|
|
5
|
+
environment?: string;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
expectTimeout?: number;
|
|
8
|
+
}
|
|
9
|
+
/** Everything about a run that depends on the chosen environment */
|
|
10
|
+
export interface RunSettings {
|
|
11
|
+
environment: string;
|
|
12
|
+
baseURL?: string;
|
|
13
|
+
/** Time (ms) one test may take */
|
|
14
|
+
timeout: number;
|
|
15
|
+
/** Time (ms) actions and assertions retry */
|
|
16
|
+
expectTimeout: number;
|
|
17
|
+
variables: Variables;
|
|
18
|
+
}
|
|
19
|
+
export declare class UnknownEnvironmentError extends Error {
|
|
20
|
+
constructor(message: string);
|
|
21
|
+
}
|
|
22
|
+
/** Picks the environment and applies precedence: run override > environment > project > built-in default */
|
|
23
|
+
export declare function resolveRunSettings(config: SamuraiTestConfig, overrides?: RunOverrides): RunSettings;
|
|
24
|
+
/** Makes `settings` the run's settings; `undefined` ends the run (tests use this to reset) */
|
|
25
|
+
export declare function setRunSettings(settings: RunSettings | undefined): void;
|
|
26
|
+
/** The active run's settings, or undefined outside a run (unit and browser tests) */
|
|
27
|
+
export declare function peekRunSettings(): RunSettings | undefined;
|
|
28
|
+
export declare function getRunSettings(): RunSettings;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT = 30000;
|
|
2
|
+
const DEFAULT_EXPECT_TIMEOUT = 5000;
|
|
3
|
+
/** The environment of a config without `environments` */
|
|
4
|
+
const IMPLICIT_ENVIRONMENT = "default";
|
|
5
|
+
export class UnknownEnvironmentError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "UnknownEnvironmentError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Picks the environment and applies precedence: run override > environment > project > built-in default */
|
|
12
|
+
export function resolveRunSettings(config, overrides = {}) {
|
|
13
|
+
const environments = config.environments ?? {};
|
|
14
|
+
const names = Object.keys(environments);
|
|
15
|
+
const available = `Available: ${names.join(", ")}`;
|
|
16
|
+
let name = overrides.environment ?? config.defaultEnvironment;
|
|
17
|
+
if (name === undefined) {
|
|
18
|
+
if (names.length === 0)
|
|
19
|
+
name = IMPLICIT_ENVIRONMENT;
|
|
20
|
+
else if (names.length === 1)
|
|
21
|
+
name = names[0];
|
|
22
|
+
else
|
|
23
|
+
throw new UnknownEnvironmentError(`No environment chosen. ${available} (use --env or set defaultEnvironment)`);
|
|
24
|
+
}
|
|
25
|
+
const environment = Object.prototype.hasOwnProperty.call(environments, name)
|
|
26
|
+
? environments[name]
|
|
27
|
+
: undefined;
|
|
28
|
+
if (!environment && !(names.length === 0 && name === IMPLICIT_ENVIRONMENT)) {
|
|
29
|
+
throw new UnknownEnvironmentError(names.length === 0
|
|
30
|
+
? `Unknown environment "${name}". No environments are defined in samurai.config.ts`
|
|
31
|
+
: `Unknown environment "${name}". ${available}`);
|
|
32
|
+
}
|
|
33
|
+
const baseURL = environment?.baseURL ?? config.baseURL;
|
|
34
|
+
return {
|
|
35
|
+
environment: name,
|
|
36
|
+
...(baseURL !== undefined && { baseURL }),
|
|
37
|
+
timeout: overrides.timeout ??
|
|
38
|
+
environment?.timeout ??
|
|
39
|
+
config.timeout ??
|
|
40
|
+
DEFAULT_TIMEOUT,
|
|
41
|
+
expectTimeout: overrides.expectTimeout ??
|
|
42
|
+
environment?.expect?.timeout ??
|
|
43
|
+
config.expect?.timeout ??
|
|
44
|
+
DEFAULT_EXPECT_TIMEOUT,
|
|
45
|
+
variables: { ...(environment?.variables ?? {}) },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
let active;
|
|
49
|
+
/** Makes `settings` the run's settings; `undefined` ends the run (tests use this to reset) */
|
|
50
|
+
export function setRunSettings(settings) {
|
|
51
|
+
active = settings;
|
|
52
|
+
}
|
|
53
|
+
/** The active run's settings, or undefined outside a run (unit and browser tests) */
|
|
54
|
+
export function peekRunSettings() {
|
|
55
|
+
return active;
|
|
56
|
+
}
|
|
57
|
+
export function getRunSettings() {
|
|
58
|
+
if (!active)
|
|
59
|
+
throw new Error("No test run is active; run settings are set when the runner starts");
|
|
60
|
+
return active;
|
|
61
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare class MissingSecretError extends Error {
|
|
2
|
+
constructor(name: string, environment: string);
|
|
3
|
+
}
|
|
4
|
+
/** Secrets by name: `.env.<environment>` in `cwd` first, then env vars (which win) */
|
|
5
|
+
export declare function loadSecrets(environment: string, options?: {
|
|
6
|
+
cwd?: string;
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
}): Map<string, string>;
|
|
9
|
+
/** The test's `secrets`: values by name, read-only; JSON and inspect show only masks */
|
|
10
|
+
export declare function createSecretsFixture(environment: string, values: ReadonlyMap<string, string>): Readonly<Record<string, string>>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { inspect, parseEnv } from "node:util";
|
|
4
|
+
import { projectDir } from "./config.js";
|
|
5
|
+
import logger from "../logger/index.js";
|
|
6
|
+
import { MASK } from "./mask.js";
|
|
7
|
+
import { isPassthroughKey } from "./variables.js";
|
|
8
|
+
const PREFIX = "SAMURAI_SECRET_";
|
|
9
|
+
const NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
10
|
+
export class MissingSecretError extends Error {
|
|
11
|
+
constructor(name, environment) {
|
|
12
|
+
super(`Secret "${name}" is not set (expected env var ${PREFIX}${name} or .env.${environment})`);
|
|
13
|
+
this.name = "MissingSecretError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Secrets by name: `.env.<environment>` in `cwd` first, then env vars (which win) */
|
|
17
|
+
export function loadSecrets(environment, options = {}) {
|
|
18
|
+
const file = path.join(options.cwd ?? projectDir(), `.env.${environment}`);
|
|
19
|
+
const sources = [];
|
|
20
|
+
if (existsSync(file))
|
|
21
|
+
sources.push(parseEnv(readFileSync(file, "utf8")));
|
|
22
|
+
sources.push(options.env ?? process.env);
|
|
23
|
+
const secrets = new Map();
|
|
24
|
+
for (const source of sources) {
|
|
25
|
+
for (const [key, value] of Object.entries(source)) {
|
|
26
|
+
if (!key.startsWith(PREFIX) || value === undefined || value === "")
|
|
27
|
+
continue;
|
|
28
|
+
const name = key.slice(PREFIX.length);
|
|
29
|
+
if (!NAME.test(name)) {
|
|
30
|
+
logger.warn(`Ignoring ${key}: secret names must be UPPER_SNAKE_CASE`);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
secrets.set(name, value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return secrets;
|
|
37
|
+
}
|
|
38
|
+
/** The test's `secrets`: values by name, read-only; JSON and inspect show only masks */
|
|
39
|
+
export function createSecretsFixture(environment, values) {
|
|
40
|
+
const masked = () => Object.fromEntries([...values.keys()].map((name) => [name, MASK]));
|
|
41
|
+
// Not frozen: the get trap returns the real value, which a frozen target's invariants forbid
|
|
42
|
+
const target = masked();
|
|
43
|
+
Object.defineProperty(target, "toJSON", { value: masked, enumerable: false });
|
|
44
|
+
Object.defineProperty(target, inspect.custom, {
|
|
45
|
+
value: masked,
|
|
46
|
+
enumerable: false,
|
|
47
|
+
});
|
|
48
|
+
const readOnly = () => {
|
|
49
|
+
throw new TypeError("secrets are read-only");
|
|
50
|
+
};
|
|
51
|
+
return new Proxy(target, {
|
|
52
|
+
get(target, key, receiver) {
|
|
53
|
+
if (isPassthroughKey(target, key))
|
|
54
|
+
return Reflect.get(target, key, receiver);
|
|
55
|
+
const name = String(key);
|
|
56
|
+
if (!NAME.test(name))
|
|
57
|
+
throw new Error(`Invalid secret name "${name}" (use UPPER_SNAKE_CASE)`);
|
|
58
|
+
const value = values.get(name);
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
throw new MissingSecretError(name, environment);
|
|
61
|
+
return value;
|
|
62
|
+
},
|
|
63
|
+
set: readOnly,
|
|
64
|
+
defineProperty: readOnly,
|
|
65
|
+
deleteProperty: readOnly,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Variables } from "./run-settings.js";
|
|
2
|
+
export declare class UnknownVariableError extends Error {
|
|
3
|
+
constructor(name: string, environment: string);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Keys that every object answers and that `await`, JSON.stringify and util.inspect probe:
|
|
7
|
+
* symbols, `then`, `toJSON` and Object.prototype members. Reading them never throws.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isPassthroughKey(target: object, key: string | symbol): boolean;
|
|
10
|
+
/** The test's `env`: the environment's variables, read-only; an unknown name throws */
|
|
11
|
+
export declare function createEnvFixture(environment: string, variables: Variables): Readonly<Variables>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class UnknownVariableError extends Error {
|
|
2
|
+
constructor(name, environment) {
|
|
3
|
+
super(`Variable "${name}" is not defined in environment "${environment}"`);
|
|
4
|
+
this.name = "UnknownVariableError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Keys that every object answers and that `await`, JSON.stringify and util.inspect probe:
|
|
9
|
+
* symbols, `then`, `toJSON` and Object.prototype members. Reading them never throws.
|
|
10
|
+
*/
|
|
11
|
+
export function isPassthroughKey(target, key) {
|
|
12
|
+
return (typeof key === "symbol" ||
|
|
13
|
+
key === "then" ||
|
|
14
|
+
key === "toJSON" ||
|
|
15
|
+
(!Object.prototype.hasOwnProperty.call(target, key) && key in target));
|
|
16
|
+
}
|
|
17
|
+
/** The test's `env`: the environment's variables, read-only; an unknown name throws */
|
|
18
|
+
export function createEnvFixture(environment, variables) {
|
|
19
|
+
return new Proxy(Object.freeze({ ...variables }), {
|
|
20
|
+
get(target, key, receiver) {
|
|
21
|
+
if (isPassthroughKey(target, key) || Object.prototype.hasOwnProperty.call(target, key))
|
|
22
|
+
return Reflect.get(target, key, receiver);
|
|
23
|
+
throw new UnknownVariableError(String(key), environment);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { main } from "./cli/main.js";
|
|
2
|
+
// Development entry (`bun run dev`): the same as `samurai run`, straight from the sources
|
|
3
|
+
const interrupt = new AbortController();
|
|
4
|
+
process.on("SIGINT", () => {
|
|
5
|
+
if (interrupt.signal.aborted)
|
|
6
|
+
process.exit(130);
|
|
7
|
+
interrupt.abort();
|
|
8
|
+
});
|
|
9
|
+
process.exit(await main(process.argv.slice(2), {
|
|
10
|
+
out: (text) => process.stdout.write(text),
|
|
11
|
+
err: (text) => process.stderr.write(text),
|
|
12
|
+
color: Boolean(process.stdout.isTTY) && !process.env.NO_COLOR,
|
|
13
|
+
env: process.env,
|
|
14
|
+
signal: interrupt.signal,
|
|
15
|
+
}));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CheckResults, WaitForState } from "./element-state.js";
|
|
2
|
+
export type ActionTimeoutReason = "unreadable" | "not-attached" | "not-actionable" | "wrong-state";
|
|
3
|
+
export interface ActionTimeoutDetails {
|
|
4
|
+
/** Locator method that timed out, e.g. "click" */
|
|
5
|
+
action: string;
|
|
6
|
+
selector: string;
|
|
7
|
+
timeout: number;
|
|
8
|
+
reason: ActionTimeoutReason;
|
|
9
|
+
/** Check results from the last probe, in check order */
|
|
10
|
+
checks?: CheckResults | undefined;
|
|
11
|
+
/** Description of the element covering the target, when the hit-target check failed */
|
|
12
|
+
coveredBy?: string | undefined;
|
|
13
|
+
/** Target state, for waitFor */
|
|
14
|
+
state?: WaitForState | undefined;
|
|
15
|
+
}
|
|
16
|
+
export declare class ActionTimeoutError extends Error {
|
|
17
|
+
readonly action: string;
|
|
18
|
+
readonly selector: string;
|
|
19
|
+
readonly timeout: number;
|
|
20
|
+
readonly reason: ActionTimeoutReason;
|
|
21
|
+
readonly checks: CheckResults | undefined;
|
|
22
|
+
readonly coveredBy: string | undefined;
|
|
23
|
+
readonly state: WaitForState | undefined;
|
|
24
|
+
constructor(details: ActionTimeoutDetails);
|
|
25
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const SYMBOLS = { pass: "✓", fail: "✗", pending: "—" };
|
|
2
|
+
function formatMessage({ action, selector, timeout, reason, checks, coveredBy, state, }) {
|
|
3
|
+
switch (reason) {
|
|
4
|
+
case "unreadable":
|
|
5
|
+
return `${action}(): could not read ${selector} within ${timeout}ms`;
|
|
6
|
+
case "not-attached":
|
|
7
|
+
return `${action}(): ${selector} was not attached within ${timeout}ms`;
|
|
8
|
+
case "wrong-state":
|
|
9
|
+
return `${action}(): ${selector} did not become ${state} within ${timeout}ms`;
|
|
10
|
+
case "not-actionable": {
|
|
11
|
+
const summary = Object.entries(checks ?? {})
|
|
12
|
+
.map(([name, result]) => {
|
|
13
|
+
const covered = name === "hit target" && result === "fail" && coveredBy
|
|
14
|
+
? ` (covered by ${coveredBy})`
|
|
15
|
+
: "";
|
|
16
|
+
return `${name} ${SYMBOLS[result]}${covered}`;
|
|
17
|
+
})
|
|
18
|
+
.join(" ");
|
|
19
|
+
return `${action}(): ${selector} was not actionable within ${timeout}ms\n ${summary}`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class ActionTimeoutError extends Error {
|
|
24
|
+
constructor(details) {
|
|
25
|
+
super(formatMessage(details));
|
|
26
|
+
this.name = "ActionTimeoutError";
|
|
27
|
+
this.action = details.action;
|
|
28
|
+
this.selector = details.selector;
|
|
29
|
+
this.timeout = details.timeout;
|
|
30
|
+
this.reason = details.reason;
|
|
31
|
+
this.checks = details.checks;
|
|
32
|
+
this.coveredBy = details.coveredBy;
|
|
33
|
+
this.state = details.state;
|
|
34
|
+
}
|
|
35
|
+
}
|