@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
|
@@ -1,1821 +0,0 @@
|
|
|
1
|
-
# Test API Alignment Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
-
|
|
5
|
-
**Goal:** Tests take one fixtures object (`{ page, browser, env, secrets }`), can be grouped with `describe`, navigate with `page.goto("/path")` relative to the environment's base URL, and read per-environment variables and secrets, with secret values masked in logs and the report.
|
|
6
|
-
|
|
7
|
-
**Architecture:** A pure `resolveRunSettings` turns the config plus CLI overrides into one `RunSettings` (environment, baseURL, timeouts, variables) that the CLI makes active once per run. Secrets are loaded from `SAMURAI_SECRET_*` env vars (and an optional `.env.<environment>` file) and every value is registered with a masker that the winston logger and the reporter run all output through. Test registration moves to its own module (`registry.ts`) with a `describe` title stack; the runner builds the fixtures per test.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** TypeScript (`module: nodenext`, `strict`, `verbatimModuleSyntax`, `noUncheckedIndexedAccess`), tsx, Node 26 built-ins (`node:test`, `node:assert/strict`, `util.parseArgs`, `util.parseEnv`), winston 3.19 / logform 2.7, WebDriver BiDi (Firefox).
|
|
10
|
-
|
|
11
|
-
**Spec:** `docs/superpowers/specs/2026-10-01-test-api-alignment-design.md`
|
|
12
|
-
|
|
13
|
-
## Global Constraints
|
|
14
|
-
|
|
15
|
-
- No new dependencies. Unit tests use `node:test` via `npm test` (`tsx --test "src/**/*.test.ts"`); browser tests via `npm run test:browser`.
|
|
16
|
-
- Relative imports use the `.js` extension; type-only imports use `import type`.
|
|
17
|
-
- Mask string: `"••••"`. Short secret: fewer than 4 characters, matched as a whole token only (`(?<![\p{L}\p{N}])value(?![\p{L}\p{N}])`, flags `gu`).
|
|
18
|
-
- Secret env var prefix `SAMURAI_SECRET_`; names match `^[A-Z][A-Z0-9_]*$`; file `.env.<environment>` in the project root; a real env var beats the file.
|
|
19
|
-
- Built-in defaults: test timeout `30000` ms, expect timeout `5000` ms; implicit environment name `"default"`.
|
|
20
|
-
- Full test title: `describe` titles and the test title joined with `" > "`.
|
|
21
|
-
- Error messages, verbatim:
|
|
22
|
-
- `Variable "<name>" is not defined in environment "<env>"`
|
|
23
|
-
- `Secret "<NAME>" is not set (expected env var SAMURAI_SECRET_<NAME> or .env.<env>)`
|
|
24
|
-
- `Invalid secret name "<name>" (use UPPER_SNAKE_CASE)`
|
|
25
|
-
- `Unknown environment "<name>". Available: <a>, <b>`
|
|
26
|
-
- `No environment chosen. Available: <a>, <b> (use --env or set defaultEnvironment)`
|
|
27
|
-
- `page.goto("<url>") needs a baseURL; set one in samurai.config.ts or the environment`
|
|
28
|
-
- `describe() callback must be synchronous; it only registers tests`
|
|
29
|
-
- `Secret "<NAME>" is short; masking it may hide unrelated text in logs`
|
|
30
|
-
- `tsc --noEmit -p .` baseline today has errors in `src/config/default.ts` and `src/runner/test-runner.ts`. Task 1 fixes `default.ts`; Task 7 fixes `test-runner.ts`. No task may add new errors; after Task 7 there are **zero**.
|
|
31
|
-
- Never run `npm run dev` / `src/tests/index.spec.ts` without asking the user: it submits a real contact form on itmetsam.nl.
|
|
32
|
-
- Leave `result/report.json` and `bun.lock` uncommitted; never `git add -A`.
|
|
33
|
-
- Commit messages end with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
|
34
|
-
|
|
35
|
-
## Review Focus
|
|
36
|
-
|
|
37
|
-
1. **`file` of a registered test is the spec file, not the framework.** Today `test()` takes the first `(...)` of the stack, which is its own frame, so every test reports `test-runner.ts`. With results keyed by file + title this would merge tests. Pinned in Task 4 (`records the calling spec file`).
|
|
38
|
-
2. **A secret inside a thrown error** (e.g. an assertion's `expected`, or a fill error quoting the value) must not reach `report.json`. Pinned in Task 5 (`masks secrets in errors and logs`).
|
|
39
|
-
3. **Secrets with regex characters** (`p@ss.w*rd+(1)`) must be masked literally, not crash or over-match. Pinned in Task 2 (`escapes regex characters`).
|
|
40
|
-
4. **`.env.<environment>` written by hand** — comments, quotes, blank lines, `export`-less lines — parses like a normal dotenv file. Pinned in Task 3 (`parses comments and quotes in .env files`).
|
|
41
|
-
5. **Bad CLI input** — `--env` with no value, `--timeout abc`, `--timeout -5` — fails with a clear message before any browser launches. Pinned in Task 1 (`rejects bad overrides`).
|
|
42
|
-
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
## File Structure
|
|
46
|
-
|
|
47
|
-
| File | Status | Responsibility |
|
|
48
|
-
|---|---|---|
|
|
49
|
-
| `src/types/config.d.ts` | modify | `baseUrl` → `baseURL`; env `baseURL`; `timeout`, `environments`, `defaultEnvironment` optional |
|
|
50
|
-
| `src/config/config.ts` | modify | `loadConfig()` (whole config); `readConfig` uses it |
|
|
51
|
-
| `src/config/run-settings.ts` | create | `resolveRunSettings`, `parseRunOverrides`, active-run state |
|
|
52
|
-
| `src/config/run-settings.test.ts` | create | |
|
|
53
|
-
| `src/config/mask.ts` | create | secret registry, `maskText`, `maskDeep`, winston `maskFormat` |
|
|
54
|
-
| `src/config/mask.test.ts` | create | |
|
|
55
|
-
| `src/logger/index.ts` | modify | add `maskFormat()` to the pipeline |
|
|
56
|
-
| `src/config/variables.ts` | create | `env` fixture Proxy, `UnknownVariableError`, `isPassthroughKey` |
|
|
57
|
-
| `src/config/secrets.ts` | create | `loadSecrets`, `secrets` fixture Proxy, `MissingSecretError` |
|
|
58
|
-
| `src/config/fixtures.test.ts` | create | tests for `variables.ts` and `secrets.ts` |
|
|
59
|
-
| `src/types/test.d.ts` | modify | `TestFixtures`, new test function signature, `titlePath`, summary `environment` |
|
|
60
|
-
| `src/runner/registry.ts` | create | `describe`, `test`, registered tests, caller file |
|
|
61
|
-
| `src/runner/registry.test.ts` | create | |
|
|
62
|
-
| `src/runner/reporter.ts` | modify | keyed by file + title, masking, `environment`, `output` option |
|
|
63
|
-
| `src/runner/reporter.test.ts` | create | |
|
|
64
|
-
| `src/browser/page.ts` | modify | `goto`, `setBaseURL` |
|
|
65
|
-
| `src/browser/page.test.ts` | modify | `goto` unit tests |
|
|
66
|
-
| `src/browser/goto.browser-test.ts` | create | `goto("/path")` against a local server |
|
|
67
|
-
| `src/runner/prepare-run.ts` | create | resolve settings + load and register secrets + activate |
|
|
68
|
-
| `src/runner/prepare-run.test.ts` | create | |
|
|
69
|
-
| `src/runner/test-runner.ts` | modify | use registry, fixtures, run settings; fix baseline tsc errors |
|
|
70
|
-
| `src/wait/wait-until.ts` | modify | `resolveTimeout` reads active run settings first |
|
|
71
|
-
| `src/wait/wait-until.test.ts` | modify | |
|
|
72
|
-
| `src/index.ts` | modify | CLI: parse overrides, prepare run, start runner |
|
|
73
|
-
| `src/api.ts` | create | public exports |
|
|
74
|
-
| `package.json` | modify | `"exports"` |
|
|
75
|
-
| `src/tests/index.spec.ts` | modify | new signature, imports from `../api.js` |
|
|
76
|
-
| `.gitignore`, `.env.example` | modify / create | secret files hygiene |
|
|
77
|
-
|
|
78
|
-
---
|
|
79
|
-
|
|
80
|
-
### Task 1: Config types and run settings
|
|
81
|
-
|
|
82
|
-
**Files:**
|
|
83
|
-
- Modify: `src/types/config.d.ts`
|
|
84
|
-
- Modify: `src/config/config.ts`
|
|
85
|
-
- Create: `src/config/run-settings.ts`
|
|
86
|
-
- Test: `src/config/run-settings.test.ts`
|
|
87
|
-
|
|
88
|
-
**Interfaces:**
|
|
89
|
-
- Consumes: `SamuraiTestConfig` from `src/types/config.d.ts`.
|
|
90
|
-
- Produces:
|
|
91
|
-
- `type Variables = Record<string, string | number | boolean>`
|
|
92
|
-
- `interface RunOverrides { environment?: string; timeout?: number; expectTimeout?: number }`
|
|
93
|
-
- `interface RunSettings { environment: string; baseURL?: string; timeout: number; expectTimeout: number; variables: Variables }`
|
|
94
|
-
- `resolveRunSettings(config: SamuraiTestConfig, overrides?: RunOverrides): RunSettings`
|
|
95
|
-
- `parseRunOverrides(argv: string[], env?: NodeJS.ProcessEnv): RunOverrides`
|
|
96
|
-
- `setRunSettings(settings: RunSettings | undefined): void`, `peekRunSettings(): RunSettings | undefined`, `getRunSettings(): RunSettings`
|
|
97
|
-
- `class UnknownEnvironmentError extends Error`
|
|
98
|
-
- `loadConfig(): Promise<SamuraiTestConfig>` in `src/config/config.ts`
|
|
99
|
-
|
|
100
|
-
- [ ] **Step 1: Update the config types**
|
|
101
|
-
|
|
102
|
-
In `src/types/config.d.ts`:
|
|
103
|
-
|
|
104
|
-
In `SamuraiEnvironment`, add as the first member:
|
|
105
|
-
```ts
|
|
106
|
-
/** Where page.goto('/…') resolves to in this environment, and where recordings start */
|
|
107
|
-
baseURL?: string;
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
In `SamuraiTestConfig`, change `timeout: number;` to `timeout?: number;`, rename `baseUrl?: string;` to `baseURL?: string;`, and change the last two members to:
|
|
111
|
-
```ts
|
|
112
|
-
/** Keyed by name: 'dev', 'staging', 'production' */
|
|
113
|
-
environments?: Record<string, SamuraiEnvironment>;
|
|
114
|
-
/** Used when no environment is chosen with --env or SAMURAI_ENV */
|
|
115
|
-
defaultEnvironment?: string;
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
Also export `SamuraiEnvironment` (change `export interface SamuraiEnvironment` if it is not exported already — it is, keep it).
|
|
119
|
-
|
|
120
|
-
- [ ] **Step 2: Add `loadConfig`**
|
|
121
|
-
|
|
122
|
-
Replace the body of `src/config/config.ts` with:
|
|
123
|
-
```ts
|
|
124
|
-
import type { SamuraiTestConfig } from "../types/config.js";
|
|
125
|
-
import path from "path";
|
|
126
|
-
import logger from "../logger/index.js";
|
|
127
|
-
import { pathToFileURL } from "url";
|
|
128
|
-
|
|
129
|
-
/** The whole `samurai.config.ts` of the current working directory */
|
|
130
|
-
export async function loadConfig(): Promise<SamuraiTestConfig> {
|
|
131
|
-
const configUrl = path.join(process.cwd(), "samurai.config.ts");
|
|
132
|
-
logger.debug("Reading config from: %s", configUrl);
|
|
133
|
-
|
|
134
|
-
const config = (await import(pathToFileURL(configUrl).href)) as {
|
|
135
|
-
default?: SamuraiTestConfig;
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
if (!config.default) {
|
|
139
|
-
throw new Error(
|
|
140
|
-
"samurai.config.ts does not export defineConfig as default!",
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
return config.default;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export async function readConfig<K extends keyof SamuraiTestConfig>(
|
|
147
|
-
key: K,
|
|
148
|
-
): Promise<SamuraiTestConfig[K]> {
|
|
149
|
-
const value = (await loadConfig())[key];
|
|
150
|
-
logger.debug("Resolved config value: ", { value });
|
|
151
|
-
return value;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export function defineConfig(config: SamuraiTestConfig) {
|
|
155
|
-
return config;
|
|
156
|
-
}
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
- [ ] **Step 3: Write the failing tests**
|
|
160
|
-
|
|
161
|
-
Create `src/config/run-settings.test.ts`:
|
|
162
|
-
```ts
|
|
163
|
-
import { afterEach, describe, test } from "node:test";
|
|
164
|
-
import assert from "node:assert/strict";
|
|
165
|
-
import type { SamuraiTestConfig } from "../types/config.js";
|
|
166
|
-
import {
|
|
167
|
-
getRunSettings,
|
|
168
|
-
parseRunOverrides,
|
|
169
|
-
peekRunSettings,
|
|
170
|
-
resolveRunSettings,
|
|
171
|
-
setRunSettings,
|
|
172
|
-
UnknownEnvironmentError,
|
|
173
|
-
} from "./run-settings.js";
|
|
174
|
-
|
|
175
|
-
const twoEnvironments: SamuraiTestConfig = {
|
|
176
|
-
baseURL: "https://harbor.shop",
|
|
177
|
-
timeout: 20000,
|
|
178
|
-
expect: { timeout: 3000 },
|
|
179
|
-
environments: {
|
|
180
|
-
dev: { baseURL: "http://localhost:3000", variables: { customerEmail: "dev@example.test" } },
|
|
181
|
-
staging: { timeout: 45000, expect: { timeout: 8000 }, variables: { customerEmail: "sam@example.test" } },
|
|
182
|
-
},
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
describe("resolveRunSettings: choosing the environment", () => {
|
|
186
|
-
test("an override wins over defaultEnvironment", () => {
|
|
187
|
-
const settings = resolveRunSettings({ ...twoEnvironments, defaultEnvironment: "staging" }, { environment: "dev" });
|
|
188
|
-
assert.equal(settings.environment, "dev");
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test("defaultEnvironment is used without an override", () => {
|
|
192
|
-
assert.equal(resolveRunSettings({ ...twoEnvironments, defaultEnvironment: "staging" }).environment, "staging");
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test("the only environment is used when nothing is chosen", () => {
|
|
196
|
-
assert.equal(resolveRunSettings({ environments: { qa: {} } }).environment, "qa");
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
test("no environments gives the implicit default environment with top-level values", () => {
|
|
200
|
-
assert.deepEqual(resolveRunSettings({ baseURL: "https://harbor.shop", timeout: 1000 }), {
|
|
201
|
-
environment: "default",
|
|
202
|
-
baseURL: "https://harbor.shop",
|
|
203
|
-
timeout: 1000,
|
|
204
|
-
expectTimeout: 5000,
|
|
205
|
-
variables: {},
|
|
206
|
-
});
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test("several environments and no choice throws, listing them", () => {
|
|
210
|
-
assert.throws(
|
|
211
|
-
() => resolveRunSettings(twoEnvironments),
|
|
212
|
-
(err) =>
|
|
213
|
-
err instanceof UnknownEnvironmentError &&
|
|
214
|
-
err.message === "No environment chosen. Available: dev, staging (use --env or set defaultEnvironment)",
|
|
215
|
-
);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test("an unknown name throws, listing the available ones", () => {
|
|
219
|
-
assert.throws(
|
|
220
|
-
() => resolveRunSettings(twoEnvironments, { environment: "stg" }),
|
|
221
|
-
(err) => err instanceof UnknownEnvironmentError && err.message === 'Unknown environment "stg". Available: dev, staging',
|
|
222
|
-
);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
test("a named environment when none are defined throws", () => {
|
|
226
|
-
assert.throws(
|
|
227
|
-
() => resolveRunSettings({}, { environment: "staging" }),
|
|
228
|
-
(err) =>
|
|
229
|
-
err instanceof UnknownEnvironmentError &&
|
|
230
|
-
err.message === 'Unknown environment "staging". No environments are defined in samurai.config.ts',
|
|
231
|
-
);
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
describe("resolveRunSettings: precedence", () => {
|
|
236
|
-
test("environment values beat project values", () => {
|
|
237
|
-
const settings = resolveRunSettings(twoEnvironments, { environment: "staging" });
|
|
238
|
-
assert.equal(settings.timeout, 45000);
|
|
239
|
-
assert.equal(settings.expectTimeout, 8000);
|
|
240
|
-
assert.equal(settings.baseURL, "https://harbor.shop");
|
|
241
|
-
assert.deepEqual(settings.variables, { customerEmail: "sam@example.test" });
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
test("project values fill what the environment leaves out", () => {
|
|
245
|
-
const settings = resolveRunSettings(twoEnvironments, { environment: "dev" });
|
|
246
|
-
assert.equal(settings.timeout, 20000);
|
|
247
|
-
assert.equal(settings.expectTimeout, 3000);
|
|
248
|
-
assert.equal(settings.baseURL, "http://localhost:3000");
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test("run overrides beat everything", () => {
|
|
252
|
-
const settings = resolveRunSettings(twoEnvironments, { environment: "staging", timeout: 1, expectTimeout: 2 });
|
|
253
|
-
assert.equal(settings.timeout, 1);
|
|
254
|
-
assert.equal(settings.expectTimeout, 2);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test("built-in defaults apply last", () => {
|
|
258
|
-
const settings = resolveRunSettings({ environments: { qa: {} } });
|
|
259
|
-
assert.equal(settings.timeout, 30000);
|
|
260
|
-
assert.equal(settings.expectTimeout, 5000);
|
|
261
|
-
assert.equal("baseURL" in settings, false);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
test("variables are a copy", () => {
|
|
265
|
-
const config: SamuraiTestConfig = { environments: { qa: { variables: { a: 1 } } } };
|
|
266
|
-
resolveRunSettings(config).variables.a = 2;
|
|
267
|
-
assert.equal(config.environments?.qa?.variables?.a, 1);
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
describe("parseRunOverrides", () => {
|
|
272
|
-
test("reads --env, --timeout and --expect-timeout", () => {
|
|
273
|
-
assert.deepEqual(parseRunOverrides(["--env", "dev", "--timeout", "1000", "--expect-timeout=200"]), {
|
|
274
|
-
environment: "dev",
|
|
275
|
-
timeout: 1000,
|
|
276
|
-
expectTimeout: 200,
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
test("falls back to SAMURAI_ENV", () => {
|
|
281
|
-
assert.deepEqual(parseRunOverrides([], { SAMURAI_ENV: "staging" }), { environment: "staging" });
|
|
282
|
-
assert.deepEqual(parseRunOverrides(["--env", "dev"], { SAMURAI_ENV: "staging" }), { environment: "dev" });
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
test("nothing given is no overrides", () => {
|
|
286
|
-
assert.deepEqual(parseRunOverrides([], {}), {});
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
test("rejects bad overrides", () => {
|
|
290
|
-
assert.throws(() => parseRunOverrides(["--env"]), /--env/);
|
|
291
|
-
assert.throws(() => parseRunOverrides(["--timeout", "abc"]), {
|
|
292
|
-
message: '--timeout must be a whole number of milliseconds, got "abc"',
|
|
293
|
-
});
|
|
294
|
-
// A value starting with "-" must use "=" (parseArgs treats "--timeout -5" as a missing value)
|
|
295
|
-
assert.throws(() => parseRunOverrides(["--timeout=-5"]), /--timeout must be a whole number/);
|
|
296
|
-
assert.throws(() => parseRunOverrides(["--expect-timeout", ""]), /--expect-timeout must be a whole number/);
|
|
297
|
-
});
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
describe("active run settings", () => {
|
|
301
|
-
afterEach(() => setRunSettings(undefined));
|
|
302
|
-
|
|
303
|
-
test("nothing is active until set", () => {
|
|
304
|
-
assert.equal(peekRunSettings(), undefined);
|
|
305
|
-
assert.throws(() => getRunSettings(), /No test run is active/);
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
test("set settings are returned", () => {
|
|
309
|
-
const settings = resolveRunSettings({});
|
|
310
|
-
setRunSettings(settings);
|
|
311
|
-
assert.equal(peekRunSettings(), settings);
|
|
312
|
-
assert.equal(getRunSettings(), settings);
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
- [ ] **Step 4: Run the tests to see them fail**
|
|
318
|
-
|
|
319
|
-
Run: `npx tsx --test src/config/run-settings.test.ts`
|
|
320
|
-
Expected: FAIL — `Cannot find module './run-settings.js'`.
|
|
321
|
-
|
|
322
|
-
- [ ] **Step 5: Implement `src/config/run-settings.ts`**
|
|
323
|
-
|
|
324
|
-
```ts
|
|
325
|
-
import { parseArgs } from "node:util";
|
|
326
|
-
import type { SamuraiTestConfig } from "../types/config.js";
|
|
327
|
-
|
|
328
|
-
export type Variables = Record<string, string | number | boolean>;
|
|
329
|
-
|
|
330
|
-
/** Values given for one run (CLI flags, SAMURAI_ENV); they beat the config */
|
|
331
|
-
export interface RunOverrides {
|
|
332
|
-
environment?: string;
|
|
333
|
-
timeout?: number;
|
|
334
|
-
expectTimeout?: number;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/** Everything about a run that depends on the chosen environment */
|
|
338
|
-
export interface RunSettings {
|
|
339
|
-
environment: string;
|
|
340
|
-
baseURL?: string;
|
|
341
|
-
/** Time (ms) one test may take */
|
|
342
|
-
timeout: number;
|
|
343
|
-
/** Time (ms) actions and assertions retry */
|
|
344
|
-
expectTimeout: number;
|
|
345
|
-
variables: Variables;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const DEFAULT_TIMEOUT = 30000;
|
|
349
|
-
const DEFAULT_EXPECT_TIMEOUT = 5000;
|
|
350
|
-
/** The environment of a config without `environments` */
|
|
351
|
-
const IMPLICIT_ENVIRONMENT = "default";
|
|
352
|
-
|
|
353
|
-
export class UnknownEnvironmentError extends Error {
|
|
354
|
-
constructor(message: string) {
|
|
355
|
-
super(message);
|
|
356
|
-
this.name = "UnknownEnvironmentError";
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Picks the environment and applies precedence: run override > environment > project > built-in default */
|
|
361
|
-
export function resolveRunSettings(config: SamuraiTestConfig, overrides: RunOverrides = {}): RunSettings {
|
|
362
|
-
const environments = config.environments ?? {};
|
|
363
|
-
const names = Object.keys(environments);
|
|
364
|
-
const available = `Available: ${names.join(", ")}`;
|
|
365
|
-
|
|
366
|
-
let name = overrides.environment ?? config.defaultEnvironment;
|
|
367
|
-
if (name === undefined) {
|
|
368
|
-
if (names.length === 0) name = IMPLICIT_ENVIRONMENT;
|
|
369
|
-
else if (names.length === 1) name = names[0]!;
|
|
370
|
-
else throw new UnknownEnvironmentError(`No environment chosen. ${available} (use --env or set defaultEnvironment)`);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const environment = environments[name];
|
|
374
|
-
if (!environment && !(names.length === 0 && name === IMPLICIT_ENVIRONMENT)) {
|
|
375
|
-
throw new UnknownEnvironmentError(
|
|
376
|
-
names.length === 0
|
|
377
|
-
? `Unknown environment "${name}". No environments are defined in samurai.config.ts`
|
|
378
|
-
: `Unknown environment "${name}". ${available}`,
|
|
379
|
-
);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const baseURL = environment?.baseURL ?? config.baseURL;
|
|
383
|
-
return {
|
|
384
|
-
environment: name,
|
|
385
|
-
...(baseURL !== undefined && { baseURL }),
|
|
386
|
-
timeout: overrides.timeout ?? environment?.timeout ?? config.timeout ?? DEFAULT_TIMEOUT,
|
|
387
|
-
expectTimeout:
|
|
388
|
-
overrides.expectTimeout ?? environment?.expect?.timeout ?? config.expect?.timeout ?? DEFAULT_EXPECT_TIMEOUT,
|
|
389
|
-
variables: { ...(environment?.variables ?? {}) },
|
|
390
|
-
};
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/** Reads `--env`, `--timeout` and `--expect-timeout` from `argv`; the environment falls back to SAMURAI_ENV */
|
|
394
|
-
export function parseRunOverrides(argv: string[], env: NodeJS.ProcessEnv = {}): RunOverrides {
|
|
395
|
-
const { values } = parseArgs({
|
|
396
|
-
args: argv,
|
|
397
|
-
options: {
|
|
398
|
-
env: { type: "string" },
|
|
399
|
-
timeout: { type: "string" },
|
|
400
|
-
"expect-timeout": { type: "string" },
|
|
401
|
-
},
|
|
402
|
-
strict: true,
|
|
403
|
-
allowPositionals: true,
|
|
404
|
-
});
|
|
405
|
-
const overrides: RunOverrides = {};
|
|
406
|
-
const environment = values.env ?? env.SAMURAI_ENV;
|
|
407
|
-
if (environment) overrides.environment = environment;
|
|
408
|
-
if (values.timeout !== undefined) overrides.timeout = milliseconds("--timeout", values.timeout);
|
|
409
|
-
if (values["expect-timeout"] !== undefined) {
|
|
410
|
-
overrides.expectTimeout = milliseconds("--expect-timeout", values["expect-timeout"]);
|
|
411
|
-
}
|
|
412
|
-
return overrides;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
function milliseconds(flag: string, raw: string): number {
|
|
416
|
-
const value = Number(raw);
|
|
417
|
-
if (raw.trim() === "" || !Number.isInteger(value) || value < 0) {
|
|
418
|
-
throw new Error(`${flag} must be a whole number of milliseconds, got "${raw}"`);
|
|
419
|
-
}
|
|
420
|
-
return value;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
let active: RunSettings | undefined;
|
|
424
|
-
|
|
425
|
-
/** Makes `settings` the run's settings; `undefined` ends the run (tests use this to reset) */
|
|
426
|
-
export function setRunSettings(settings: RunSettings | undefined): void {
|
|
427
|
-
active = settings;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/** The active run's settings, or undefined outside a run (unit and browser tests) */
|
|
431
|
-
export function peekRunSettings(): RunSettings | undefined {
|
|
432
|
-
return active;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
export function getRunSettings(): RunSettings {
|
|
436
|
-
if (!active) throw new Error("No test run is active; run settings are set when the runner starts");
|
|
437
|
-
return active;
|
|
438
|
-
}
|
|
439
|
-
```
|
|
440
|
-
|
|
441
|
-
- [ ] **Step 6: Run the tests to see them pass**
|
|
442
|
-
|
|
443
|
-
Run: `npx tsx --test src/config/run-settings.test.ts`
|
|
444
|
-
Expected: PASS, all tests.
|
|
445
|
-
|
|
446
|
-
- [ ] **Step 7: Typecheck**
|
|
447
|
-
|
|
448
|
-
Run: `npx tsc --noEmit -p .`
|
|
449
|
-
Expected: the `src/config/default.ts` error is gone; only the pre-existing `src/runner/test-runner.ts` errors remain.
|
|
450
|
-
|
|
451
|
-
- [ ] **Step 8: Commit**
|
|
452
|
-
|
|
453
|
-
```bash
|
|
454
|
-
git add src/types/config.d.ts src/config/config.ts src/config/run-settings.ts src/config/run-settings.test.ts
|
|
455
|
-
git commit -m "feat(config): resolve run settings per environment
|
|
456
|
-
|
|
457
|
-
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
|
458
|
-
```
|
|
459
|
-
|
|
460
|
-
---
|
|
461
|
-
|
|
462
|
-
### Task 2: Secret masking
|
|
463
|
-
|
|
464
|
-
**Files:**
|
|
465
|
-
- Create: `src/config/mask.ts`
|
|
466
|
-
- Modify: `src/logger/index.ts`
|
|
467
|
-
- Test: `src/config/mask.test.ts`
|
|
468
|
-
|
|
469
|
-
**Interfaces:**
|
|
470
|
-
- Consumes: nothing from earlier tasks.
|
|
471
|
-
- Produces:
|
|
472
|
-
- `const MASK = "••••"`
|
|
473
|
-
- `registerSecret(name: string, value: string): void` (empty values ignored)
|
|
474
|
-
- `resetSecrets(): void` (tests only)
|
|
475
|
-
- `isShortSecret(value: string): boolean` (`0 < length < 4`)
|
|
476
|
-
- `maskText(text: string): string`
|
|
477
|
-
- `maskDeep<T>(value: T): T` — strings in plain objects and arrays, returns a copy
|
|
478
|
-
- `maskFormat` — winston format factory; use as `maskFormat()`
|
|
479
|
-
|
|
480
|
-
- [ ] **Step 1: Write the failing tests**
|
|
481
|
-
|
|
482
|
-
Create `src/config/mask.test.ts`:
|
|
483
|
-
```ts
|
|
484
|
-
import { afterEach, test } from "node:test";
|
|
485
|
-
import assert from "node:assert/strict";
|
|
486
|
-
import { isShortSecret, MASK, maskDeep, maskFormat, maskText, registerSecret, resetSecrets } from "./mask.js";
|
|
487
|
-
|
|
488
|
-
afterEach(() => resetSecrets());
|
|
489
|
-
|
|
490
|
-
test("nothing registered leaves text alone", () => {
|
|
491
|
-
assert.equal(maskText("card 4242"), "card 4242");
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
test("masks every occurrence of a long value", () => {
|
|
495
|
-
registerSecret("TEST_CARD", "4242424242424242");
|
|
496
|
-
assert.equal(maskText("fill 4242424242424242 then 4242424242424242x"), `fill ${MASK} then ${MASK}x`);
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
test("escapes regex characters", () => {
|
|
500
|
-
registerSecret("PW", "p@ss.w*rd+(1)");
|
|
501
|
-
assert.equal(maskText("pw=p@ss.w*rd+(1)!"), `pw=${MASK}!`);
|
|
502
|
-
assert.equal(maskText("p@ssXw*rd+(1)"), "p@ssXw*rd+(1)");
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
test("masks the longest secret first", () => {
|
|
506
|
-
registerSecret("SHORT", "secret");
|
|
507
|
-
registerSecret("LONG", "secret-and-more");
|
|
508
|
-
assert.equal(maskText("x secret-and-more y secret"), `x ${MASK} y ${MASK}`);
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
test("short values only match whole tokens", () => {
|
|
512
|
-
registerSecret("PIN", "12");
|
|
513
|
-
assert.equal(maskText("pin 12, step 12."), `pin ${MASK}, step ${MASK}.`);
|
|
514
|
-
assert.equal(maskText("2012 and a12b"), "2012 and a12b");
|
|
515
|
-
assert.equal(maskText("12"), MASK);
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
test("empty values are ignored", () => {
|
|
519
|
-
registerSecret("EMPTY", "");
|
|
520
|
-
assert.equal(maskText("anything"), "anything");
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
test("isShortSecret", () => {
|
|
524
|
-
assert.equal(isShortSecret("abc"), true);
|
|
525
|
-
assert.equal(isShortSecret("abcd"), false);
|
|
526
|
-
assert.equal(isShortSecret(""), false);
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
test("maskDeep masks strings in nested objects and arrays and returns a copy", () => {
|
|
530
|
-
registerSecret("TEST_CARD", "4242424242424242");
|
|
531
|
-
const result = {
|
|
532
|
-
message: "expected 4242424242424242",
|
|
533
|
-
expected: "4242424242424242",
|
|
534
|
-
duration: 12,
|
|
535
|
-
logs: [{ text: "typed 4242424242424242", timestamp: 1 }],
|
|
536
|
-
};
|
|
537
|
-
const masked = maskDeep(result);
|
|
538
|
-
assert.deepEqual(masked, {
|
|
539
|
-
message: `expected ${MASK}`,
|
|
540
|
-
expected: MASK,
|
|
541
|
-
duration: 12,
|
|
542
|
-
logs: [{ text: `typed ${MASK}`, timestamp: 1 }],
|
|
543
|
-
});
|
|
544
|
-
assert.equal(result.expected, "4242424242424242");
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
test("maskFormat masks the message and metadata of a log entry", () => {
|
|
548
|
-
registerSecret("TEST_CARD", "4242424242424242");
|
|
549
|
-
const info = maskFormat().transform({
|
|
550
|
-
level: "info",
|
|
551
|
-
message: "filled 4242424242424242",
|
|
552
|
-
meta: { value: "4242424242424242" },
|
|
553
|
-
}) as { message: string; meta: { value: string } };
|
|
554
|
-
assert.equal(info.message, `filled ${MASK}`);
|
|
555
|
-
assert.equal(info.meta.value, MASK);
|
|
556
|
-
});
|
|
557
|
-
```
|
|
558
|
-
|
|
559
|
-
- [ ] **Step 2: Run the tests to see them fail**
|
|
560
|
-
|
|
561
|
-
Run: `npx tsx --test src/config/mask.test.ts`
|
|
562
|
-
Expected: FAIL — `Cannot find module './mask.js'`.
|
|
563
|
-
|
|
564
|
-
- [ ] **Step 3: Implement `src/config/mask.ts`**
|
|
565
|
-
|
|
566
|
-
```ts
|
|
567
|
-
import { format } from "winston";
|
|
568
|
-
|
|
569
|
-
export const MASK = "••••";
|
|
570
|
-
/** Values shorter than this only match as a whole token, so "1" does not hide every digit */
|
|
571
|
-
const SHORT_LENGTH = 4;
|
|
572
|
-
|
|
573
|
-
const secrets = new Map<string, string>();
|
|
574
|
-
let patterns: RegExp[] = [];
|
|
575
|
-
|
|
576
|
-
/** Registers a secret value; every later maskText/maskDeep/log line hides it */
|
|
577
|
-
export function registerSecret(name: string, value: string): void {
|
|
578
|
-
if (value === "") return;
|
|
579
|
-
secrets.set(name, value);
|
|
580
|
-
rebuild();
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/** Forgets every registered secret (tests only) */
|
|
584
|
-
export function resetSecrets(): void {
|
|
585
|
-
secrets.clear();
|
|
586
|
-
patterns = [];
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
export function isShortSecret(value: string): boolean {
|
|
590
|
-
return value.length > 0 && value.length < SHORT_LENGTH;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function escapeRegExp(value: string): string {
|
|
594
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
function rebuild(): void {
|
|
598
|
-
// Longest first, so a secret that contains another one is hidden whole
|
|
599
|
-
const values = [...new Set(secrets.values())].sort((a, b) => b.length - a.length);
|
|
600
|
-
patterns = values.map((value) =>
|
|
601
|
-
isShortSecret(value)
|
|
602
|
-
? new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(value)}(?![\\p{L}\\p{N}])`, "gu")
|
|
603
|
-
: new RegExp(escapeRegExp(value), "gu"),
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
export function maskText(text: string): string {
|
|
608
|
-
let masked = text;
|
|
609
|
-
for (const pattern of patterns) masked = masked.replace(pattern, MASK);
|
|
610
|
-
return masked;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/** A copy of `value` with every string in it (plain objects and arrays, any depth) masked */
|
|
614
|
-
export function maskDeep<T>(value: T): T {
|
|
615
|
-
if (patterns.length === 0) return value;
|
|
616
|
-
return walk(value) as T;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function walk(value: unknown): unknown {
|
|
620
|
-
if (typeof value === "string") return maskText(value);
|
|
621
|
-
if (Array.isArray(value)) return value.map(walk);
|
|
622
|
-
if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
|
|
623
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, walk(item)]));
|
|
624
|
-
}
|
|
625
|
-
return value;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/** Winston format that masks every string field of a log entry (message and metadata) */
|
|
629
|
-
export const maskFormat = format((info) => {
|
|
630
|
-
if (patterns.length === 0) return info;
|
|
631
|
-
for (const key of Object.keys(info)) info[key] = walk(info[key]);
|
|
632
|
-
return info;
|
|
633
|
-
});
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
- [ ] **Step 4: Run the tests to see them pass**
|
|
637
|
-
|
|
638
|
-
Run: `npx tsx --test src/config/mask.test.ts`
|
|
639
|
-
Expected: PASS.
|
|
640
|
-
|
|
641
|
-
- [ ] **Step 5: Add the format to the logger**
|
|
642
|
-
|
|
643
|
-
In `src/logger/index.ts`, add the import:
|
|
644
|
-
```ts
|
|
645
|
-
import { maskFormat } from "../config/mask.js";
|
|
646
|
-
```
|
|
647
|
-
and in the logger-level `format.combine(...)`, insert `maskFormat(),` directly after `format.metadata({ key: "meta" }),` (before `format.json(...)`). Logger-level formats run before each transport's own format, so console and file output are both masked.
|
|
648
|
-
|
|
649
|
-
- [ ] **Step 6: Run the whole unit suite**
|
|
650
|
-
|
|
651
|
-
Run: `npm test`
|
|
652
|
-
Expected: PASS (602 existing tests plus the new ones).
|
|
653
|
-
|
|
654
|
-
- [ ] **Step 7: Commit**
|
|
655
|
-
|
|
656
|
-
```bash
|
|
657
|
-
git add src/config/mask.ts src/config/mask.test.ts src/logger/index.ts
|
|
658
|
-
git commit -m "feat(config): mask secret values in logs
|
|
659
|
-
|
|
660
|
-
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
---
|
|
664
|
-
|
|
665
|
-
### Task 3: `env` and `secrets` fixtures
|
|
666
|
-
|
|
667
|
-
**Files:**
|
|
668
|
-
- Create: `src/config/variables.ts`
|
|
669
|
-
- Create: `src/config/secrets.ts`
|
|
670
|
-
- Test: `src/config/fixtures.test.ts`
|
|
671
|
-
|
|
672
|
-
**Interfaces:**
|
|
673
|
-
- Consumes: `Variables` from `./run-settings.js` (Task 1); `MASK` from `./mask.js` (Task 2).
|
|
674
|
-
- Produces:
|
|
675
|
-
- `createEnvFixture(environment: string, variables: Variables): Readonly<Variables>`
|
|
676
|
-
- `class UnknownVariableError extends Error`
|
|
677
|
-
- `isPassthroughKey(target: object, key: string | symbol): boolean`
|
|
678
|
-
- `loadSecrets(environment: string, options?: { cwd?: string; env?: NodeJS.ProcessEnv }): Map<string, string>`
|
|
679
|
-
- `createSecretsFixture(environment: string, values: ReadonlyMap<string, string>): Readonly<Record<string, string>>`
|
|
680
|
-
- `class MissingSecretError extends Error`
|
|
681
|
-
|
|
682
|
-
- [ ] **Step 1: Write the failing tests**
|
|
683
|
-
|
|
684
|
-
Create `src/config/fixtures.test.ts`:
|
|
685
|
-
```ts
|
|
686
|
-
import { after, describe, test } from "node:test";
|
|
687
|
-
import assert from "node:assert/strict";
|
|
688
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
689
|
-
import { tmpdir } from "node:os";
|
|
690
|
-
import path from "node:path";
|
|
691
|
-
import { inspect } from "node:util";
|
|
692
|
-
import { createEnvFixture, UnknownVariableError } from "./variables.js";
|
|
693
|
-
import { createSecretsFixture, loadSecrets, MissingSecretError } from "./secrets.js";
|
|
694
|
-
|
|
695
|
-
describe("env fixture", () => {
|
|
696
|
-
const env = createEnvFixture("staging", { customerEmail: "sam@example.test", newCheckout: true });
|
|
697
|
-
|
|
698
|
-
test("reads variables", () => {
|
|
699
|
-
assert.equal(env.customerEmail, "sam@example.test");
|
|
700
|
-
const { newCheckout } = env;
|
|
701
|
-
assert.equal(newCheckout, true);
|
|
702
|
-
});
|
|
703
|
-
|
|
704
|
-
test("an unknown variable throws", () => {
|
|
705
|
-
assert.throws(
|
|
706
|
-
() => env.customerEmial,
|
|
707
|
-
(err) =>
|
|
708
|
-
err instanceof UnknownVariableError &&
|
|
709
|
-
err.message === 'Variable "customerEmial" is not defined in environment "staging"',
|
|
710
|
-
);
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
test("is read-only", () => {
|
|
714
|
-
assert.throws(() => {
|
|
715
|
-
(env as Record<string, unknown>).customerEmail = "x";
|
|
716
|
-
}, TypeError);
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
test("behaves like a plain object for JSON, inspect and await", async () => {
|
|
720
|
-
assert.equal(JSON.stringify(env), '{"customerEmail":"sam@example.test","newCheckout":true}');
|
|
721
|
-
assert.match(inspect(env), /customerEmail/);
|
|
722
|
-
assert.equal(await env, env);
|
|
723
|
-
assert.equal("customerEmail" in env, true);
|
|
724
|
-
});
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
describe("loadSecrets", () => {
|
|
728
|
-
const dir = mkdtempSync(path.join(tmpdir(), "samurai-secrets-"));
|
|
729
|
-
after(() => rmSync(dir, { recursive: true, force: true }));
|
|
730
|
-
writeFileSync(
|
|
731
|
-
path.join(dir, ".env.staging"),
|
|
732
|
-
[
|
|
733
|
-
"# staging secrets",
|
|
734
|
-
"",
|
|
735
|
-
"SAMURAI_SECRET_TEST_CARD=4242424242424242",
|
|
736
|
-
'SAMURAI_SECRET_CUSTOMER_PW="pa ss # word"',
|
|
737
|
-
"SAMURAI_SECRET_FROM_FILE=file",
|
|
738
|
-
"OTHER_VALUE=ignored",
|
|
739
|
-
].join("\n"),
|
|
740
|
-
);
|
|
741
|
-
|
|
742
|
-
test("parses comments and quotes in .env files", () => {
|
|
743
|
-
const secrets = loadSecrets("staging", { cwd: dir, env: {} });
|
|
744
|
-
assert.deepEqual(Object.fromEntries(secrets), {
|
|
745
|
-
TEST_CARD: "4242424242424242",
|
|
746
|
-
CUSTOMER_PW: "pa ss # word",
|
|
747
|
-
FROM_FILE: "file",
|
|
748
|
-
});
|
|
749
|
-
});
|
|
750
|
-
|
|
751
|
-
test("env vars beat the file and non-prefixed keys are ignored", () => {
|
|
752
|
-
const secrets = loadSecrets("staging", {
|
|
753
|
-
cwd: dir,
|
|
754
|
-
env: { SAMURAI_SECRET_FROM_FILE: "env", SAMURAI_SECRET_ONLY_ENV: "x", PATH: "/bin" },
|
|
755
|
-
});
|
|
756
|
-
assert.equal(secrets.get("FROM_FILE"), "env");
|
|
757
|
-
assert.equal(secrets.get("ONLY_ENV"), "x");
|
|
758
|
-
assert.equal(secrets.has("PATH"), false);
|
|
759
|
-
});
|
|
760
|
-
|
|
761
|
-
test("a missing file is fine", () => {
|
|
762
|
-
assert.deepEqual(Object.fromEntries(loadSecrets("dev", { cwd: dir, env: { SAMURAI_SECRET_A: "1" } })), { A: "1" });
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
test("names that are not UPPER_SNAKE_CASE are skipped", () => {
|
|
766
|
-
assert.equal(loadSecrets("dev", { cwd: dir, env: { SAMURAI_SECRET_lower: "x" } }).size, 0);
|
|
767
|
-
});
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
describe("secrets fixture", () => {
|
|
771
|
-
const secrets = createSecretsFixture("staging", new Map([["TEST_CARD", "4242424242424242"]]));
|
|
772
|
-
|
|
773
|
-
test("reads a secret", () => {
|
|
774
|
-
assert.equal(secrets.TEST_CARD, "4242424242424242");
|
|
775
|
-
});
|
|
776
|
-
|
|
777
|
-
test("a missing secret throws", () => {
|
|
778
|
-
assert.throws(
|
|
779
|
-
() => secrets.CUSTOMER_PW,
|
|
780
|
-
(err) =>
|
|
781
|
-
err instanceof MissingSecretError &&
|
|
782
|
-
err.message === 'Secret "CUSTOMER_PW" is not set (expected env var SAMURAI_SECRET_CUSTOMER_PW or .env.staging)',
|
|
783
|
-
);
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
test("an invalid name throws", () => {
|
|
787
|
-
assert.throws(() => secrets.testCard, { message: 'Invalid secret name "testCard" (use UPPER_SNAKE_CASE)' });
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
test("JSON and inspect never show values", () => {
|
|
791
|
-
assert.equal(JSON.stringify(secrets), '{"TEST_CARD":"••••"}');
|
|
792
|
-
assert.equal(JSON.stringify({ secrets }), '{"secrets":{"TEST_CARD":"••••"}}');
|
|
793
|
-
assert.doesNotMatch(inspect(secrets), /4242/);
|
|
794
|
-
assert.match(inspect(secrets), /TEST_CARD/);
|
|
795
|
-
});
|
|
796
|
-
|
|
797
|
-
test("is read-only", () => {
|
|
798
|
-
assert.throws(() => {
|
|
799
|
-
(secrets as Record<string, string>).TEST_CARD = "x";
|
|
800
|
-
}, TypeError);
|
|
801
|
-
assert.equal(secrets.TEST_CARD, "4242424242424242");
|
|
802
|
-
});
|
|
803
|
-
|
|
804
|
-
test("await does not treat it as a thenable", async () => {
|
|
805
|
-
assert.equal(await secrets, secrets);
|
|
806
|
-
});
|
|
807
|
-
});
|
|
808
|
-
```
|
|
809
|
-
|
|
810
|
-
- [ ] **Step 2: Run the tests to see them fail**
|
|
811
|
-
|
|
812
|
-
Run: `npx tsx --test src/config/fixtures.test.ts`
|
|
813
|
-
Expected: FAIL — `Cannot find module './variables.js'`.
|
|
814
|
-
|
|
815
|
-
- [ ] **Step 3: Implement `src/config/variables.ts`**
|
|
816
|
-
|
|
817
|
-
```ts
|
|
818
|
-
import type { Variables } from "./run-settings.js";
|
|
819
|
-
|
|
820
|
-
export class UnknownVariableError extends Error {
|
|
821
|
-
constructor(name: string, environment: string) {
|
|
822
|
-
super(`Variable "${name}" is not defined in environment "${environment}"`);
|
|
823
|
-
this.name = "UnknownVariableError";
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
/**
|
|
828
|
-
* Keys that every object answers and that `await`, JSON.stringify and util.inspect probe:
|
|
829
|
-
* symbols, `then`, `toJSON` and Object.prototype members. Reading them never throws.
|
|
830
|
-
*/
|
|
831
|
-
export function isPassthroughKey(target: object, key: string | symbol): boolean {
|
|
832
|
-
return (
|
|
833
|
-
typeof key === "symbol" ||
|
|
834
|
-
key === "then" ||
|
|
835
|
-
key === "toJSON" ||
|
|
836
|
-
(!Object.hasOwn(target, key) && key in target)
|
|
837
|
-
);
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
/** The test's `env`: the environment's variables, read-only; an unknown name throws */
|
|
841
|
-
export function createEnvFixture(environment: string, variables: Variables): Readonly<Variables> {
|
|
842
|
-
return new Proxy(Object.freeze({ ...variables }), {
|
|
843
|
-
get(target, key, receiver) {
|
|
844
|
-
if (isPassthroughKey(target, key) || Object.hasOwn(target, key)) return Reflect.get(target, key, receiver);
|
|
845
|
-
throw new UnknownVariableError(String(key), environment);
|
|
846
|
-
},
|
|
847
|
-
});
|
|
848
|
-
}
|
|
849
|
-
```
|
|
850
|
-
|
|
851
|
-
- [ ] **Step 4: Implement `src/config/secrets.ts`**
|
|
852
|
-
|
|
853
|
-
```ts
|
|
854
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
855
|
-
import path from "node:path";
|
|
856
|
-
import { inspect, parseEnv } from "node:util";
|
|
857
|
-
import logger from "../logger/index.js";
|
|
858
|
-
import { MASK } from "./mask.js";
|
|
859
|
-
import { isPassthroughKey } from "./variables.js";
|
|
860
|
-
|
|
861
|
-
const PREFIX = "SAMURAI_SECRET_";
|
|
862
|
-
const NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
863
|
-
|
|
864
|
-
export class MissingSecretError extends Error {
|
|
865
|
-
constructor(name: string, environment: string) {
|
|
866
|
-
super(`Secret "${name}" is not set (expected env var ${PREFIX}${name} or .env.${environment})`);
|
|
867
|
-
this.name = "MissingSecretError";
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
/** Secrets by name: `.env.<environment>` in `cwd` first, then env vars (which win) */
|
|
872
|
-
export function loadSecrets(
|
|
873
|
-
environment: string,
|
|
874
|
-
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
|
875
|
-
): Map<string, string> {
|
|
876
|
-
const file = path.join(options.cwd ?? process.cwd(), `.env.${environment}`);
|
|
877
|
-
const sources: Array<Record<string, string | undefined>> = [];
|
|
878
|
-
if (existsSync(file)) sources.push(parseEnv(readFileSync(file, "utf8")) as Record<string, string>);
|
|
879
|
-
sources.push(options.env ?? process.env);
|
|
880
|
-
|
|
881
|
-
const secrets = new Map<string, string>();
|
|
882
|
-
for (const source of sources) {
|
|
883
|
-
for (const [key, value] of Object.entries(source)) {
|
|
884
|
-
if (!key.startsWith(PREFIX) || value === undefined) continue;
|
|
885
|
-
const name = key.slice(PREFIX.length);
|
|
886
|
-
if (!NAME.test(name)) {
|
|
887
|
-
logger.warn(`Ignoring ${key}: secret names must be UPPER_SNAKE_CASE`);
|
|
888
|
-
continue;
|
|
889
|
-
}
|
|
890
|
-
secrets.set(name, value);
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
return secrets;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
/** The test's `secrets`: values by name, read-only; JSON and inspect show only masks */
|
|
897
|
-
export function createSecretsFixture(
|
|
898
|
-
environment: string,
|
|
899
|
-
values: ReadonlyMap<string, string>,
|
|
900
|
-
): Readonly<Record<string, string>> {
|
|
901
|
-
const masked = () => Object.fromEntries([...values.keys()].map((name) => [name, MASK]));
|
|
902
|
-
// Not frozen: the get trap returns the real value, which a frozen target's invariants forbid
|
|
903
|
-
const target: Record<string, string> = masked();
|
|
904
|
-
Object.defineProperty(target, "toJSON", { value: masked, enumerable: false });
|
|
905
|
-
Object.defineProperty(target, inspect.custom, { value: masked, enumerable: false });
|
|
906
|
-
const readOnly = (): never => {
|
|
907
|
-
throw new TypeError("secrets are read-only");
|
|
908
|
-
};
|
|
909
|
-
|
|
910
|
-
return new Proxy(target, {
|
|
911
|
-
get(target, key, receiver) {
|
|
912
|
-
if (isPassthroughKey(target, key)) return Reflect.get(target, key, receiver);
|
|
913
|
-
const name = String(key);
|
|
914
|
-
if (!NAME.test(name)) throw new Error(`Invalid secret name "${name}" (use UPPER_SNAKE_CASE)`);
|
|
915
|
-
const value = values.get(name);
|
|
916
|
-
if (value === undefined) throw new MissingSecretError(name, environment);
|
|
917
|
-
return value;
|
|
918
|
-
},
|
|
919
|
-
set: readOnly,
|
|
920
|
-
defineProperty: readOnly,
|
|
921
|
-
deleteProperty: readOnly,
|
|
922
|
-
});
|
|
923
|
-
}
|
|
924
|
-
```
|
|
925
|
-
|
|
926
|
-
- [ ] **Step 5: Run the tests to see them pass**
|
|
927
|
-
|
|
928
|
-
Run: `npx tsx --test src/config/fixtures.test.ts`
|
|
929
|
-
Expected: PASS. If `inspect(secrets)` still shows a value, check that `util.inspect` inspects the Proxy's target (it does with the default `showProxy: false`) and that `inspect.custom` is defined on the target.
|
|
930
|
-
|
|
931
|
-
- [ ] **Step 6: Commit**
|
|
932
|
-
|
|
933
|
-
```bash
|
|
934
|
-
git add src/config/variables.ts src/config/secrets.ts src/config/fixtures.test.ts
|
|
935
|
-
git commit -m "feat(config): env and secrets fixtures
|
|
936
|
-
|
|
937
|
-
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
|
938
|
-
```
|
|
939
|
-
|
|
940
|
-
---
|
|
941
|
-
|
|
942
|
-
### Task 4: Test registry with `describe`
|
|
943
|
-
|
|
944
|
-
**Files:**
|
|
945
|
-
- Modify: `src/types/test.d.ts`
|
|
946
|
-
- Create: `src/runner/registry.ts`
|
|
947
|
-
- Test: `src/runner/registry.test.ts`
|
|
948
|
-
|
|
949
|
-
**Interfaces:**
|
|
950
|
-
- Consumes: nothing from earlier tasks (types only).
|
|
951
|
-
- Produces:
|
|
952
|
-
- In `src/types/test.d.ts`: `interface TestFixtures { page: Page; browser: Browser; env: Readonly<Record<string, string | number | boolean>>; secrets: Readonly<Record<string, string>> }`; `TestCaseBase.function: (fixtures: TestFixtures) => Promise<void>`; `RegisteredTestCase.titlePath: string[]`; `TestSummaryBase.environment: string`.
|
|
953
|
-
- `describe(title: string, fn: () => void): void`
|
|
954
|
-
- `test(title: string, fn: (fixtures: TestFixtures) => Promise<void>): void`
|
|
955
|
-
- `registeredTests(): readonly RegisteredTestCase[]`
|
|
956
|
-
- `clearRegistry(): void` (tests only)
|
|
957
|
-
|
|
958
|
-
- [ ] **Step 1: Update the test types**
|
|
959
|
-
|
|
960
|
-
In `src/types/test.d.ts`, replace the `RegisteredTestCase` and `TestCaseBase` interfaces with:
|
|
961
|
-
```ts
|
|
962
|
-
/** What a test function receives */
|
|
963
|
-
export interface TestFixtures {
|
|
964
|
-
page: Page;
|
|
965
|
-
browser: Browser;
|
|
966
|
-
/** The chosen environment's variables; reading an unknown name throws */
|
|
967
|
-
env: Readonly<Record<string, string | number | boolean>>;
|
|
968
|
-
/** Secrets by name (SAMURAI_SECRET_<NAME>); reading an unset name throws */
|
|
969
|
-
secrets: Readonly<Record<string, string>>;
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
export interface RegisteredTestCase extends TestCaseBase {
|
|
973
|
-
/** Path of the spec file that registered the test */
|
|
974
|
-
file: string;
|
|
975
|
-
/** describe titles, then the test title */
|
|
976
|
-
titlePath: string[];
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
export interface TestCaseBase {
|
|
980
|
-
/** Full title: describe titles and the test title joined with " > " */
|
|
981
|
-
name: string;
|
|
982
|
-
function: (fixtures: TestFixtures) => Promise<void>;
|
|
983
|
-
}
|
|
984
|
-
```
|
|
985
|
-
and add to `TestSummaryBase`:
|
|
986
|
-
```ts
|
|
987
|
-
/** The environment the run used */
|
|
988
|
-
environment: string;
|
|
989
|
-
```
|
|
990
|
-
|
|
991
|
-
- [ ] **Step 2: Write the failing tests**
|
|
992
|
-
|
|
993
|
-
Create `src/runner/registry.test.ts`:
|
|
994
|
-
```ts
|
|
995
|
-
import { afterEach, test } from "node:test";
|
|
996
|
-
import assert from "node:assert/strict";
|
|
997
|
-
import { fileURLToPath } from "node:url";
|
|
998
|
-
import {
|
|
999
|
-
clearRegistry,
|
|
1000
|
-
describe as samuraiDescribe,
|
|
1001
|
-
registeredTests,
|
|
1002
|
-
test as samuraiTest,
|
|
1003
|
-
} from "./registry.js";
|
|
1004
|
-
|
|
1005
|
-
afterEach(() => clearRegistry());
|
|
1006
|
-
|
|
1007
|
-
const noop = async () => {};
|
|
1008
|
-
|
|
1009
|
-
test("a test outside describe keeps its title", () => {
|
|
1010
|
-
samuraiTest("logs in", noop);
|
|
1011
|
-
const [registered] = registeredTests();
|
|
1012
|
-
assert.equal(registered?.name, "logs in");
|
|
1013
|
-
assert.deepEqual(registered?.titlePath, ["logs in"]);
|
|
1014
|
-
assert.equal(registered?.function, noop);
|
|
1015
|
-
});
|
|
1016
|
-
|
|
1017
|
-
test("nested describe titles are joined with >", () => {
|
|
1018
|
-
samuraiDescribe("Checkout", () => {
|
|
1019
|
-
samuraiDescribe("Guest", () => {
|
|
1020
|
-
samuraiTest("applies coupon", noop);
|
|
1021
|
-
});
|
|
1022
|
-
samuraiTest("pays with saved card", noop);
|
|
1023
|
-
});
|
|
1024
|
-
samuraiTest("after", noop);
|
|
1025
|
-
assert.deepEqual(
|
|
1026
|
-
registeredTests().map((t) => t.name),
|
|
1027
|
-
["Checkout > Guest > applies coupon", "Checkout > pays with saved card", "after"],
|
|
1028
|
-
);
|
|
1029
|
-
});
|
|
1030
|
-
|
|
1031
|
-
test("the title stack is restored when a describe callback throws", () => {
|
|
1032
|
-
assert.throws(() =>
|
|
1033
|
-
samuraiDescribe("Broken", () => {
|
|
1034
|
-
throw new Error("boom");
|
|
1035
|
-
}),
|
|
1036
|
-
);
|
|
1037
|
-
samuraiTest("after", noop);
|
|
1038
|
-
assert.equal(registeredTests()[0]?.name, "after");
|
|
1039
|
-
});
|
|
1040
|
-
|
|
1041
|
-
test("an async describe callback is rejected", () => {
|
|
1042
|
-
assert.throws(
|
|
1043
|
-
() => samuraiDescribe("Async", async () => {}),
|
|
1044
|
-
{ message: "describe() callback must be synchronous; it only registers tests" },
|
|
1045
|
-
);
|
|
1046
|
-
samuraiTest("after", noop);
|
|
1047
|
-
assert.equal(registeredTests()[0]?.name, "after");
|
|
1048
|
-
});
|
|
1049
|
-
|
|
1050
|
-
test("records the calling spec file", () => {
|
|
1051
|
-
samuraiTest("where", noop);
|
|
1052
|
-
assert.equal(registeredTests()[0]?.file, fileURLToPath(import.meta.url));
|
|
1053
|
-
});
|
|
1054
|
-
```
|
|
1055
|
-
|
|
1056
|
-
- [ ] **Step 3: Run the tests to see them fail**
|
|
1057
|
-
|
|
1058
|
-
Run: `npx tsx --test src/runner/registry.test.ts`
|
|
1059
|
-
Expected: FAIL — `Cannot find module './registry.js'`.
|
|
1060
|
-
|
|
1061
|
-
- [ ] **Step 4: Implement `src/runner/registry.ts`**
|
|
1062
|
-
|
|
1063
|
-
```ts
|
|
1064
|
-
import { fileURLToPath } from "node:url";
|
|
1065
|
-
import logger from "../logger/index.js";
|
|
1066
|
-
import type { RegisteredTestCase, TestCaseBase } from "../types/test.js";
|
|
1067
|
-
|
|
1068
|
-
const registered: RegisteredTestCase[] = [];
|
|
1069
|
-
/** Titles of the describe blocks being registered, outermost first */
|
|
1070
|
-
const titleStack: string[] = [];
|
|
1071
|
-
|
|
1072
|
-
/** Groups the tests registered inside `fn` under `title`. `fn` runs immediately and must be synchronous. */
|
|
1073
|
-
export function describe(title: string, fn: () => void): void {
|
|
1074
|
-
titleStack.push(title);
|
|
1075
|
-
try {
|
|
1076
|
-
const result: unknown = fn();
|
|
1077
|
-
if (result instanceof Promise) {
|
|
1078
|
-
result.catch(() => {});
|
|
1079
|
-
throw new Error("describe() callback must be synchronous; it only registers tests");
|
|
1080
|
-
}
|
|
1081
|
-
} finally {
|
|
1082
|
-
titleStack.pop();
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
export function test(title: string, fn: TestCaseBase["function"]): void {
|
|
1087
|
-
const titlePath = [...titleStack, title];
|
|
1088
|
-
const name = titlePath.join(" > ");
|
|
1089
|
-
registered.push({ name, titlePath, function: fn, file: callerFile() });
|
|
1090
|
-
logger.debug("Registered test %s", name);
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
export function registeredTests(): readonly RegisteredTestCase[] {
|
|
1094
|
-
return registered;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
/** Forgets every registered test (tests only) */
|
|
1098
|
-
export function clearRegistry(): void {
|
|
1099
|
-
registered.length = 0;
|
|
1100
|
-
titleStack.length = 0;
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
/** File of the code that called test(): stack lines are Error, callerFile, test, caller */
|
|
1104
|
-
function callerFile(): string {
|
|
1105
|
-
const caller = new Error().stack?.split("\n")[3] ?? "";
|
|
1106
|
-
const match = caller.match(/\((.+?):\d+:\d+\)$/) ?? caller.match(/at (.+?):\d+:\d+$/);
|
|
1107
|
-
const location = match?.[1] ?? "";
|
|
1108
|
-
return location.startsWith("file://") ? fileURLToPath(location) : location;
|
|
1109
|
-
}
|
|
1110
|
-
```
|
|
1111
|
-
|
|
1112
|
-
- [ ] **Step 5: Run the tests to see them pass**
|
|
1113
|
-
|
|
1114
|
-
Run: `npx tsx --test src/runner/registry.test.ts`
|
|
1115
|
-
Expected: PASS. If `records the calling spec file` fails, print `new Error().stack` inside `callerFile` once to confirm the frame index under tsx and fix the index, not the test.
|
|
1116
|
-
|
|
1117
|
-
- [ ] **Step 6: Commit**
|
|
1118
|
-
|
|
1119
|
-
`tsc` still reports the baseline `test-runner.ts` errors plus new ones where `test-runner.ts` uses the old `function` signature; Task 7 replaces that code.
|
|
1120
|
-
|
|
1121
|
-
```bash
|
|
1122
|
-
git add src/types/test.d.ts src/runner/registry.ts src/runner/registry.test.ts
|
|
1123
|
-
git commit -m "feat(runner): describe blocks and test registry
|
|
1124
|
-
|
|
1125
|
-
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
|
1126
|
-
```
|
|
1127
|
-
|
|
1128
|
-
---
|
|
1129
|
-
|
|
1130
|
-
### Task 5: Reporter keyed by file and title, masked, with environment
|
|
1131
|
-
|
|
1132
|
-
**Files:**
|
|
1133
|
-
- Modify: `src/runner/reporter.ts`
|
|
1134
|
-
- Test: `src/runner/reporter.test.ts`
|
|
1135
|
-
|
|
1136
|
-
**Interfaces:**
|
|
1137
|
-
- Consumes: `RegisteredTestCase` (Task 4); `maskDeep` (Task 2).
|
|
1138
|
-
- Produces: `new TestReporter({ environment: string; output?: string })`; `onTestStart(test: RegisteredTestCase)`; `onTestEnd(test: RegisteredTestCase, error?: TestError, logs?: { logs?: TestLogEntry[]; logsDropped?: number })`; `onStart()`; `onEnd(): Promise<void>` writing `output` (default `<cwd>/result/report.json`).
|
|
1139
|
-
|
|
1140
|
-
- [ ] **Step 1: Write the failing tests**
|
|
1141
|
-
|
|
1142
|
-
Create `src/runner/reporter.test.ts`:
|
|
1143
|
-
```ts
|
|
1144
|
-
import { after, afterEach, test } from "node:test";
|
|
1145
|
-
import assert from "node:assert/strict";
|
|
1146
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
1147
|
-
import { tmpdir } from "node:os";
|
|
1148
|
-
import path from "node:path";
|
|
1149
|
-
import TestReporter from "./reporter.js";
|
|
1150
|
-
import { MASK, registerSecret, resetSecrets } from "../config/mask.js";
|
|
1151
|
-
import type { RegisteredTestCase } from "../types/test.js";
|
|
1152
|
-
|
|
1153
|
-
const dir = mkdtempSync(path.join(tmpdir(), "samurai-reporter-"));
|
|
1154
|
-
after(() => rmSync(dir, { recursive: true, force: true }));
|
|
1155
|
-
afterEach(() => resetSecrets());
|
|
1156
|
-
|
|
1157
|
-
function testCase(file: string, name: string): RegisteredTestCase {
|
|
1158
|
-
return { file, name, titlePath: name.split(" > "), function: async () => {} };
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
async function report(run: (reporter: TestReporter) => void) {
|
|
1162
|
-
const output = path.join(dir, `${Math.random()}.json`);
|
|
1163
|
-
const reporter = new TestReporter({ environment: "staging", output });
|
|
1164
|
-
reporter.onStart();
|
|
1165
|
-
run(reporter);
|
|
1166
|
-
await reporter.onEnd();
|
|
1167
|
-
return JSON.parse(readFileSync(output, "utf8"));
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
test("records the environment", async () => {
|
|
1171
|
-
const summary = await report(() => {});
|
|
1172
|
-
assert.equal(summary.environment, "staging");
|
|
1173
|
-
});
|
|
1174
|
-
|
|
1175
|
-
test("tests with the same title in different files are kept apart", async () => {
|
|
1176
|
-
const a = testCase("a.spec.ts", "Checkout > pays");
|
|
1177
|
-
const b = testCase("b.spec.ts", "Checkout > pays");
|
|
1178
|
-
const summary = await report((reporter) => {
|
|
1179
|
-
reporter.onTestStart(a);
|
|
1180
|
-
reporter.onTestStart(b);
|
|
1181
|
-
reporter.onTestEnd(a);
|
|
1182
|
-
reporter.onTestEnd(b, { message: "boom", type: "error" });
|
|
1183
|
-
});
|
|
1184
|
-
assert.deepEqual(
|
|
1185
|
-
summary.tests.map((t: { file: string; name: string; status: string }) => [t.file, t.name, t.status]),
|
|
1186
|
-
[
|
|
1187
|
-
["a.spec.ts", "Checkout > pays", "success"],
|
|
1188
|
-
["b.spec.ts", "Checkout > pays", "failed"],
|
|
1189
|
-
],
|
|
1190
|
-
);
|
|
1191
|
-
});
|
|
1192
|
-
|
|
1193
|
-
test("masks secrets in errors and logs", async () => {
|
|
1194
|
-
registerSecret("TEST_CARD", "4242424242424242");
|
|
1195
|
-
const t = testCase("a.spec.ts", "pays");
|
|
1196
|
-
const summary = await report((reporter) => {
|
|
1197
|
-
reporter.onTestStart(t);
|
|
1198
|
-
reporter.onTestEnd(
|
|
1199
|
-
t,
|
|
1200
|
-
{
|
|
1201
|
-
message: 'expected "4242424242424242"',
|
|
1202
|
-
type: "assertion",
|
|
1203
|
-
stack: "AssertionError: expected 4242424242424242\n at x",
|
|
1204
|
-
expected: "4242424242424242",
|
|
1205
|
-
actual: "4242",
|
|
1206
|
-
},
|
|
1207
|
-
{ logs: [{ level: "info", type: "console", text: "card 4242424242424242", timestamp: 1 }] },
|
|
1208
|
-
);
|
|
1209
|
-
});
|
|
1210
|
-
const [result] = summary.tests;
|
|
1211
|
-
assert.equal(result.message, `expected "${MASK}"`);
|
|
1212
|
-
assert.equal(result.expected, MASK);
|
|
1213
|
-
assert.equal(result.actual, "4242");
|
|
1214
|
-
assert.doesNotMatch(result.stack, /4242424242424242/);
|
|
1215
|
-
assert.equal(result.logs[0].text, `card ${MASK}`);
|
|
1216
|
-
});
|
|
1217
|
-
```
|
|
1218
|
-
|
|
1219
|
-
- [ ] **Step 2: Run the tests to see them fail**
|
|
1220
|
-
|
|
1221
|
-
Run: `npx tsx --test src/runner/reporter.test.ts`
|
|
1222
|
-
Expected: FAIL — `environment` missing / the second test overwrites the first / values not masked.
|
|
1223
|
-
|
|
1224
|
-
- [ ] **Step 3: Update `src/runner/reporter.ts`**
|
|
1225
|
-
|
|
1226
|
-
Replace the import block and the class with:
|
|
1227
|
-
```ts
|
|
1228
|
-
import { mkdir, writeFile } from "fs/promises";
|
|
1229
|
-
import path from "path";
|
|
1230
|
-
import { maskDeep } from "../config/mask.js";
|
|
1231
|
-
import type {
|
|
1232
|
-
PartialTestResult,
|
|
1233
|
-
RegisteredTestCase,
|
|
1234
|
-
TestError,
|
|
1235
|
-
TestLogEntry,
|
|
1236
|
-
TestResult,
|
|
1237
|
-
TestSummary,
|
|
1238
|
-
} from "../types/test.js";
|
|
1239
|
-
|
|
1240
|
-
export interface ReporterOptions {
|
|
1241
|
-
/** The environment the run uses, recorded in the report */
|
|
1242
|
-
environment: string;
|
|
1243
|
-
/** Where the report is written. @default <cwd>/result/report.json */
|
|
1244
|
-
output?: string;
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
/** Results are keyed by file and full title, so equal titles in different files or describe blocks stay apart */
|
|
1248
|
-
function keyOf(test: RegisteredTestCase): string {
|
|
1249
|
-
return `${test.file}::${test.name}`;
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
export default class TestReporter {
|
|
1253
|
-
private testResults: Map<string, TestResult> = new Map();
|
|
1254
|
-
private summary: TestSummary | undefined;
|
|
1255
|
-
private environment: string;
|
|
1256
|
-
private output: string;
|
|
1257
|
-
|
|
1258
|
-
constructor(options: ReporterOptions) {
|
|
1259
|
-
this.environment = options.environment;
|
|
1260
|
-
this.output = options.output ?? path.join(process.cwd(), "result/report.json");
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
public onStart() {
|
|
1264
|
-
const t = performance.mark(`group-start`);
|
|
1265
|
-
this.summary = {
|
|
1266
|
-
duration: 0,
|
|
1267
|
-
status: "failed",
|
|
1268
|
-
startTime: t.startTime,
|
|
1269
|
-
environment: this.environment,
|
|
1270
|
-
tests: [],
|
|
1271
|
-
};
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
public async onEnd() {
|
|
1275
|
-
performance.mark(`group-finish`);
|
|
1276
|
-
const duration = performance.measure(
|
|
1277
|
-
"test-duration",
|
|
1278
|
-
`group-start`,
|
|
1279
|
-
`group-finish`,
|
|
1280
|
-
).duration;
|
|
1281
|
-
|
|
1282
|
-
this.summary = {
|
|
1283
|
-
...this.summary,
|
|
1284
|
-
duration,
|
|
1285
|
-
environment: this.environment,
|
|
1286
|
-
tests: Array.from(this.testResults.values()),
|
|
1287
|
-
status: Array.from(this.testResults.values()).every(
|
|
1288
|
-
(r: TestResult) => r.status === "success",
|
|
1289
|
-
)
|
|
1290
|
-
? "success"
|
|
1291
|
-
: "failed",
|
|
1292
|
-
} as TestSummary;
|
|
1293
|
-
|
|
1294
|
-
await mkdir(path.dirname(this.output), { recursive: true });
|
|
1295
|
-
await writeFile(this.output, JSON.stringify(this.summary, null, 2), {
|
|
1296
|
-
encoding: "utf8",
|
|
1297
|
-
});
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
public onTestStart(test: RegisteredTestCase) {
|
|
1301
|
-
const key = keyOf(test);
|
|
1302
|
-
const t = performance.mark(`${key}-start`);
|
|
1303
|
-
this.testResults.set(key, {
|
|
1304
|
-
name: test.name,
|
|
1305
|
-
file: test.file,
|
|
1306
|
-
status: "started",
|
|
1307
|
-
startTime: t.startTime,
|
|
1308
|
-
});
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
public onTestEnd(
|
|
1312
|
-
test: RegisteredTestCase,
|
|
1313
|
-
error?: TestError,
|
|
1314
|
-
logs?: { logs?: TestLogEntry[]; logsDropped?: number },
|
|
1315
|
-
) {
|
|
1316
|
-
const key = keyOf(test);
|
|
1317
|
-
performance.mark(`${key}-finish`);
|
|
1318
|
-
const duration = performance.measure(
|
|
1319
|
-
"test-duration",
|
|
1320
|
-
`${key}-start`,
|
|
1321
|
-
`${key}-finish`,
|
|
1322
|
-
).duration;
|
|
1323
|
-
|
|
1324
|
-
this.testResults.set(
|
|
1325
|
-
key,
|
|
1326
|
-
maskDeep({
|
|
1327
|
-
...(this.testResults.get(key) as PartialTestResult),
|
|
1328
|
-
duration,
|
|
1329
|
-
status: error ? "failed" : "success",
|
|
1330
|
-
...(error ? error : undefined),
|
|
1331
|
-
...(logs?.logs && { logs: logs.logs }),
|
|
1332
|
-
...(logs?.logsDropped && { logsDropped: logs.logsDropped }),
|
|
1333
|
-
} as TestResult),
|
|
1334
|
-
);
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
```
|
|
1338
|
-
|
|
1339
|
-
- [ ] **Step 4: Run the tests to see them pass**
|
|
1340
|
-
|
|
1341
|
-
Run: `npx tsx --test src/runner/reporter.test.ts`
|
|
1342
|
-
Expected: PASS.
|
|
1343
|
-
|
|
1344
|
-
- [ ] **Step 5: Commit**
|
|
1345
|
-
|
|
1346
|
-
```bash
|
|
1347
|
-
git add src/runner/reporter.ts src/runner/reporter.test.ts
|
|
1348
|
-
git commit -m "feat(runner): report keyed by file and title, masked, with environment
|
|
1349
|
-
|
|
1350
|
-
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
|
1351
|
-
```
|
|
1352
|
-
|
|
1353
|
-
---
|
|
1354
|
-
|
|
1355
|
-
### Task 6: `page.goto` with base URL
|
|
1356
|
-
|
|
1357
|
-
**Files:**
|
|
1358
|
-
- Modify: `src/browser/page.ts`
|
|
1359
|
-
- Modify: `src/browser/page.test.ts`
|
|
1360
|
-
- Create: `src/browser/goto.browser-test.ts`
|
|
1361
|
-
|
|
1362
|
-
**Interfaces:**
|
|
1363
|
-
- Consumes: `peekRunSettings`, `setRunSettings`, `resolveRunSettings` (Task 1).
|
|
1364
|
-
- Produces: `Page.goto(url: string, options?: NavigateOptions): Promise<{ navigation: string | null; url: string }>`; `Page.setBaseURL(url: string | undefined): void`.
|
|
1365
|
-
|
|
1366
|
-
- [ ] **Step 1: Write the failing unit tests**
|
|
1367
|
-
|
|
1368
|
-
Add to the imports of `src/browser/page.test.ts`:
|
|
1369
|
-
```ts
|
|
1370
|
-
import { resolveRunSettings, setRunSettings } from "../config/run-settings.js";
|
|
1371
|
-
```
|
|
1372
|
-
and append:
|
|
1373
|
-
```ts
|
|
1374
|
-
describe("Page.goto", () => {
|
|
1375
|
-
const urls = (sent: Array<{ params: unknown }>) => sent.map(({ params }) => (params as { url: string }).url);
|
|
1376
|
-
|
|
1377
|
-
test("resolves relative URLs against the page's base URL", async () => {
|
|
1378
|
-
const { connector, sent } = stubConnector({ type: "undefined" });
|
|
1379
|
-
const page = new Page(connector, "ctx");
|
|
1380
|
-
page.setBaseURL("https://staging.harbor.shop/shop/");
|
|
1381
|
-
await page.goto("/products");
|
|
1382
|
-
await page.goto("./cart");
|
|
1383
|
-
await page.goto("../account");
|
|
1384
|
-
assert.deepEqual(urls(sent), [
|
|
1385
|
-
"https://staging.harbor.shop/products",
|
|
1386
|
-
"https://staging.harbor.shop/shop/cart",
|
|
1387
|
-
"https://staging.harbor.shop/account",
|
|
1388
|
-
]);
|
|
1389
|
-
});
|
|
1390
|
-
|
|
1391
|
-
test("keeps absolute URLs and bare hosts like navigateTo", async () => {
|
|
1392
|
-
const { connector, sent } = stubConnector({ type: "undefined" });
|
|
1393
|
-
const page = new Page(connector, "ctx");
|
|
1394
|
-
page.setBaseURL("https://staging.harbor.shop");
|
|
1395
|
-
await page.goto("https://other.test/x");
|
|
1396
|
-
await page.goto("itmetsam.nl");
|
|
1397
|
-
assert.deepEqual(urls(sent), ["https://other.test/x", "https://itmetsam.nl"]);
|
|
1398
|
-
});
|
|
1399
|
-
|
|
1400
|
-
test("uses the active run's base URL when the page has none", async () => {
|
|
1401
|
-
setRunSettings(resolveRunSettings({ baseURL: "https://harbor.shop" }));
|
|
1402
|
-
try {
|
|
1403
|
-
const { connector, sent } = stubConnector({ type: "undefined" });
|
|
1404
|
-
await new Page(connector, "ctx").goto("/login");
|
|
1405
|
-
assert.deepEqual(urls(sent), ["https://harbor.shop/login"]);
|
|
1406
|
-
} finally {
|
|
1407
|
-
setRunSettings(undefined);
|
|
1408
|
-
}
|
|
1409
|
-
});
|
|
1410
|
-
|
|
1411
|
-
test("a relative URL without a base URL throws", async () => {
|
|
1412
|
-
const { connector, sent } = stubConnector({ type: "undefined" });
|
|
1413
|
-
await assert.rejects(new Page(connector, "ctx").goto("/products"), {
|
|
1414
|
-
message: 'page.goto("/products") needs a baseURL; set one in samurai.config.ts or the environment',
|
|
1415
|
-
});
|
|
1416
|
-
assert.equal(sent.length, 0);
|
|
1417
|
-
});
|
|
1418
|
-
});
|
|
1419
|
-
```
|
|
1420
|
-
|
|
1421
|
-
- [ ] **Step 2: Run the tests to see them fail**
|
|
1422
|
-
|
|
1423
|
-
Run: `npx tsx --test src/browser/page.test.ts`
|
|
1424
|
-
Expected: FAIL — `page.setBaseURL is not a function`.
|
|
1425
|
-
|
|
1426
|
-
- [ ] **Step 3: Implement in `src/browser/page.ts`**
|
|
1427
|
-
|
|
1428
|
-
Add the import next to the other config import:
|
|
1429
|
-
```ts
|
|
1430
|
-
import { peekRunSettings } from "../config/run-settings.js";
|
|
1431
|
-
```
|
|
1432
|
-
Add a field after `private lastNavigation: string | undefined;`:
|
|
1433
|
-
```ts
|
|
1434
|
-
/** Base for relative page.goto URLs; falls back to the active run's baseURL */
|
|
1435
|
-
private baseURL: string | undefined;
|
|
1436
|
-
```
|
|
1437
|
-
Add these methods directly above `public async navigateTo(`:
|
|
1438
|
-
```ts
|
|
1439
|
-
/** Sets the base that relative `goto` URLs resolve against; `undefined` falls back to the run's baseURL */
|
|
1440
|
-
public setBaseURL(url: string | undefined): void {
|
|
1441
|
-
this.baseURL = url;
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
/**
|
|
1445
|
-
* Navigates like `navigateTo`, but a URL starting with "/", "./" or "../" resolves against the base URL
|
|
1446
|
-
* (`setBaseURL`, else the environment's `baseURL`).
|
|
1447
|
-
* @example
|
|
1448
|
-
* await page.goto("/products");
|
|
1449
|
-
* await page.goto("https://example.com", { wait: "interactive" });
|
|
1450
|
-
*/
|
|
1451
|
-
public async goto(
|
|
1452
|
-
url: string,
|
|
1453
|
-
options?: NavigateOptions,
|
|
1454
|
-
): Promise<{ navigation: string | null; url: string }> {
|
|
1455
|
-
if (!/^\.{0,2}\//.test(url)) return this.navigateTo(url, options);
|
|
1456
|
-
const base = this.baseURL ?? peekRunSettings()?.baseURL;
|
|
1457
|
-
if (!base) {
|
|
1458
|
-
throw new Error(`page.goto("${url}") needs a baseURL; set one in samurai.config.ts or the environment`);
|
|
1459
|
-
}
|
|
1460
|
-
return this.navigateTo(new URL(url, base).href, options);
|
|
1461
|
-
}
|
|
1462
|
-
```
|
|
1463
|
-
|
|
1464
|
-
- [ ] **Step 4: Run the tests to see them pass**
|
|
1465
|
-
|
|
1466
|
-
Run: `npx tsx --test src/browser/page.test.ts`
|
|
1467
|
-
Expected: PASS, including the existing `navigateTo` tests.
|
|
1468
|
-
|
|
1469
|
-
- [ ] **Step 5: Write the browser test**
|
|
1470
|
-
|
|
1471
|
-
Create `src/browser/goto.browser-test.ts`:
|
|
1472
|
-
```ts
|
|
1473
|
-
import assert from "node:assert/strict";
|
|
1474
|
-
import { createServer, type Server } from "node:http";
|
|
1475
|
-
import type { AddressInfo, Socket } from "node:net";
|
|
1476
|
-
import { after, before, test } from "node:test";
|
|
1477
|
-
import { Browser } from "./browser.js";
|
|
1478
|
-
import type Page from "./page.js";
|
|
1479
|
-
|
|
1480
|
-
let browser: Browser;
|
|
1481
|
-
let page: Page;
|
|
1482
|
-
let server: Server;
|
|
1483
|
-
let base: string;
|
|
1484
|
-
const sockets = new Set<Socket>();
|
|
1485
|
-
|
|
1486
|
-
before(async () => {
|
|
1487
|
-
server = createServer((req, res) => {
|
|
1488
|
-
res.setHeader("content-type", "text/html").end(`<!doctype html><title>${req.url}</title>`);
|
|
1489
|
-
});
|
|
1490
|
-
server.on("connection", (socket) => {
|
|
1491
|
-
sockets.add(socket);
|
|
1492
|
-
socket.on("close", () => sockets.delete(socket));
|
|
1493
|
-
});
|
|
1494
|
-
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
1495
|
-
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
1496
|
-
({ browser, page } = await Browser.launch("firefox", { port: 9251, headless: true }));
|
|
1497
|
-
});
|
|
1498
|
-
|
|
1499
|
-
after(async () => {
|
|
1500
|
-
await page.dispose();
|
|
1501
|
-
await browser?.close();
|
|
1502
|
-
sockets.forEach((socket) => socket.destroy());
|
|
1503
|
-
await new Promise((resolve) => server.close(resolve));
|
|
1504
|
-
});
|
|
1505
|
-
|
|
1506
|
-
test("goto resolves a path against the base URL", { timeout: 40000 }, async () => {
|
|
1507
|
-
page.setBaseURL(`${base}/shop/`);
|
|
1508
|
-
await page.goto("/products?id=1");
|
|
1509
|
-
assert.equal(await page.url(), `${base}/products?id=1`);
|
|
1510
|
-
assert.equal(await page.title(), "/products?id=1");
|
|
1511
|
-
await page.goto("./cart");
|
|
1512
|
-
assert.equal(await page.url(), `${base}/shop/cart`);
|
|
1513
|
-
});
|
|
1514
|
-
```
|
|
1515
|
-
|
|
1516
|
-
- [ ] **Step 6: Run the browser test**
|
|
1517
|
-
|
|
1518
|
-
Run: `npx tsx --test src/browser/goto.browser-test.ts`
|
|
1519
|
-
Expected: PASS (needs the local Firefox in `browsers/`, as the other browser tests do).
|
|
1520
|
-
|
|
1521
|
-
- [ ] **Step 7: Commit**
|
|
1522
|
-
|
|
1523
|
-
```bash
|
|
1524
|
-
git add src/browser/page.ts src/browser/page.test.ts src/browser/goto.browser-test.ts
|
|
1525
|
-
git commit -m "feat(page): goto resolves paths against the base URL
|
|
1526
|
-
|
|
1527
|
-
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
|
1528
|
-
```
|
|
1529
|
-
|
|
1530
|
-
---
|
|
1531
|
-
|
|
1532
|
-
### Task 7: Wire the run: prepare, runner, CLI, public API
|
|
1533
|
-
|
|
1534
|
-
**Files:**
|
|
1535
|
-
- Create: `src/runner/prepare-run.ts`
|
|
1536
|
-
- Test: `src/runner/prepare-run.test.ts`
|
|
1537
|
-
- Modify: `src/runner/test-runner.ts`
|
|
1538
|
-
- Modify: `src/wait/wait-until.ts`, `src/wait/wait-until.test.ts`
|
|
1539
|
-
- Modify: `src/index.ts`
|
|
1540
|
-
- Create: `src/api.ts`
|
|
1541
|
-
- Modify: `package.json`, `.gitignore`, `src/tests/index.spec.ts`
|
|
1542
|
-
- Create: `.env.example`
|
|
1543
|
-
|
|
1544
|
-
**Interfaces:**
|
|
1545
|
-
- Consumes: everything above — `resolveRunSettings`, `setRunSettings`, `peekRunSettings`, `parseRunOverrides`, `RunOverrides`, `RunSettings` (Task 1); `registerSecret`, `isShortSecret` (Task 2); `loadSecrets`, `createSecretsFixture`, `createEnvFixture` (Task 3); `registeredTests`, `describe`, `test` (Task 4); `TestReporter` options (Task 5); `loadConfig` (Task 1).
|
|
1546
|
-
- Produces:
|
|
1547
|
-
- `interface PreparedRun { settings: RunSettings; secrets: ReadonlyMap<string, string> }`
|
|
1548
|
-
- `prepareRun(config: SamuraiTestConfig, overrides: RunOverrides, options?: { cwd?: string; env?: NodeJS.ProcessEnv }): PreparedRun`
|
|
1549
|
-
- `TestRunner.init(run: PreparedRun, group?: SamuraiGroup)`
|
|
1550
|
-
- Public module `src/api.ts` (`test`, `describe`, `expect`, `defineConfig`, type `TestFixtures`)
|
|
1551
|
-
|
|
1552
|
-
- [ ] **Step 1: Write the failing tests**
|
|
1553
|
-
|
|
1554
|
-
Create `src/runner/prepare-run.test.ts`:
|
|
1555
|
-
```ts
|
|
1556
|
-
import { after, afterEach, test } from "node:test";
|
|
1557
|
-
import assert from "node:assert/strict";
|
|
1558
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
1559
|
-
import { tmpdir } from "node:os";
|
|
1560
|
-
import path from "node:path";
|
|
1561
|
-
import { prepareRun } from "./prepare-run.js";
|
|
1562
|
-
import { peekRunSettings, setRunSettings } from "../config/run-settings.js";
|
|
1563
|
-
import { MASK, maskText, resetSecrets } from "../config/mask.js";
|
|
1564
|
-
|
|
1565
|
-
const dir = mkdtempSync(path.join(tmpdir(), "samurai-run-"));
|
|
1566
|
-
writeFileSync(path.join(dir, ".env.staging"), "SAMURAI_SECRET_TEST_CARD=4242424242424242\n");
|
|
1567
|
-
after(() => rmSync(dir, { recursive: true, force: true }));
|
|
1568
|
-
afterEach(() => {
|
|
1569
|
-
setRunSettings(undefined);
|
|
1570
|
-
resetSecrets();
|
|
1571
|
-
});
|
|
1572
|
-
|
|
1573
|
-
test("resolves settings, loads secrets for that environment and activates the run", () => {
|
|
1574
|
-
const run = prepareRun(
|
|
1575
|
-
{ environments: { dev: {}, staging: { baseURL: "https://staging.harbor.shop" } } },
|
|
1576
|
-
{ environment: "staging" },
|
|
1577
|
-
{ cwd: dir, env: { SAMURAI_SECRET_PIN: "12" } },
|
|
1578
|
-
);
|
|
1579
|
-
assert.equal(run.settings.environment, "staging");
|
|
1580
|
-
assert.equal(peekRunSettings(), run.settings);
|
|
1581
|
-
assert.deepEqual(Object.fromEntries(run.secrets), { TEST_CARD: "4242424242424242", PIN: "12" });
|
|
1582
|
-
assert.equal(maskText("card 4242424242424242 pin 12"), `card ${MASK} pin ${MASK}`);
|
|
1583
|
-
});
|
|
1584
|
-
|
|
1585
|
-
test("an unknown environment fails before anything is activated", () => {
|
|
1586
|
-
assert.throws(() => prepareRun({ environments: { dev: {} } }, { environment: "stg" }, { cwd: dir, env: {} }));
|
|
1587
|
-
assert.equal(peekRunSettings(), undefined);
|
|
1588
|
-
});
|
|
1589
|
-
```
|
|
1590
|
-
|
|
1591
|
-
Append to `src/wait/wait-until.test.ts` (add `setRunSettings`, `resolveRunSettings` to a new import from `"../config/run-settings.js"`):
|
|
1592
|
-
```ts
|
|
1593
|
-
test("resolveTimeout uses the active run's expect timeout", async () => {
|
|
1594
|
-
setRunSettings(resolveRunSettings({}, { expectTimeout: 1234 }));
|
|
1595
|
-
try {
|
|
1596
|
-
assert.equal(await resolveTimeout(), 1234);
|
|
1597
|
-
assert.equal(await resolveTimeout(10), 10);
|
|
1598
|
-
} finally {
|
|
1599
|
-
setRunSettings(undefined);
|
|
1600
|
-
}
|
|
1601
|
-
});
|
|
1602
|
-
```
|
|
1603
|
-
|
|
1604
|
-
- [ ] **Step 2: Run the tests to see them fail**
|
|
1605
|
-
|
|
1606
|
-
Run: `npx tsx --test src/runner/prepare-run.test.ts src/wait/wait-until.test.ts`
|
|
1607
|
-
Expected: FAIL — `Cannot find module './prepare-run.js'`, and `resolveTimeout` returns the config value instead of `1234`.
|
|
1608
|
-
|
|
1609
|
-
- [ ] **Step 3: Implement `src/runner/prepare-run.ts`**
|
|
1610
|
-
|
|
1611
|
-
```ts
|
|
1612
|
-
import { isShortSecret, registerSecret } from "../config/mask.js";
|
|
1613
|
-
import {
|
|
1614
|
-
resolveRunSettings,
|
|
1615
|
-
setRunSettings,
|
|
1616
|
-
type RunOverrides,
|
|
1617
|
-
type RunSettings,
|
|
1618
|
-
} from "../config/run-settings.js";
|
|
1619
|
-
import { loadSecrets } from "../config/secrets.js";
|
|
1620
|
-
import logger from "../logger/index.js";
|
|
1621
|
-
import type { SamuraiTestConfig } from "../types/config.js";
|
|
1622
|
-
|
|
1623
|
-
export interface PreparedRun {
|
|
1624
|
-
settings: RunSettings;
|
|
1625
|
-
secrets: ReadonlyMap<string, string>;
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
/**
|
|
1629
|
-
* Starts a run: picks the environment, loads its secrets and registers every value for masking
|
|
1630
|
-
* (all of them, not only the ones a test reads), then makes the settings active.
|
|
1631
|
-
*/
|
|
1632
|
-
export function prepareRun(
|
|
1633
|
-
config: SamuraiTestConfig,
|
|
1634
|
-
overrides: RunOverrides,
|
|
1635
|
-
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
|
1636
|
-
): PreparedRun {
|
|
1637
|
-
const settings = resolveRunSettings(config, overrides);
|
|
1638
|
-
const secrets = loadSecrets(settings.environment, options);
|
|
1639
|
-
for (const [name, value] of secrets) {
|
|
1640
|
-
registerSecret(name, value);
|
|
1641
|
-
if (isShortSecret(value)) {
|
|
1642
|
-
logger.warn(`Secret "${name}" is short; masking it may hide unrelated text in logs`);
|
|
1643
|
-
}
|
|
1644
|
-
}
|
|
1645
|
-
setRunSettings(settings);
|
|
1646
|
-
return { settings, secrets };
|
|
1647
|
-
}
|
|
1648
|
-
```
|
|
1649
|
-
|
|
1650
|
-
- [ ] **Step 4: Update `resolveTimeout` in `src/wait/wait-until.ts`**
|
|
1651
|
-
|
|
1652
|
-
Add `import { peekRunSettings } from "../config/run-settings.js";` and replace `resolveTimeout` with:
|
|
1653
|
-
```ts
|
|
1654
|
-
/** Per-call timeout, else the active run's expect timeout, else config `expect.timeout`, else 5000 */
|
|
1655
|
-
export async function resolveTimeout(perCall?: number): Promise<number> {
|
|
1656
|
-
if (perCall !== undefined) return perCall;
|
|
1657
|
-
const run = peekRunSettings();
|
|
1658
|
-
if (run) return run.expectTimeout;
|
|
1659
|
-
return (await readConfig("expect"))?.timeout ?? DEFAULT_TIMEOUT;
|
|
1660
|
-
}
|
|
1661
|
-
```
|
|
1662
|
-
|
|
1663
|
-
- [ ] **Step 5: Run the tests to see them pass**
|
|
1664
|
-
|
|
1665
|
-
Run: `npx tsx --test src/runner/prepare-run.test.ts src/wait/wait-until.test.ts`
|
|
1666
|
-
Expected: PASS.
|
|
1667
|
-
|
|
1668
|
-
- [ ] **Step 6: Update `src/runner/test-runner.ts`**
|
|
1669
|
-
|
|
1670
|
-
1. Imports: remove `import type { RegisteredTestCase, TestResult } from "../types/test.js";` and add:
|
|
1671
|
-
```ts
|
|
1672
|
-
import type { RegisteredTestCase, TestFixtures } from "../types/test.js";
|
|
1673
|
-
import { registeredTests } from "./registry.js";
|
|
1674
|
-
import type { PreparedRun } from "./prepare-run.js";
|
|
1675
|
-
import { createEnvFixture } from "../config/variables.js";
|
|
1676
|
-
import { createSecretsFixture } from "../config/secrets.js";
|
|
1677
|
-
```
|
|
1678
|
-
(Keep the other imports; drop `TestResult` only if nothing else uses it — `tsc` will tell you.)
|
|
1679
|
-
2. Delete `const registeredTestcases: RegisteredTestCase[] = [];` and the whole `export function test(...)` at the bottom. Add at the bottom instead:
|
|
1680
|
-
```ts
|
|
1681
|
-
export { describe, test } from "./registry.js";
|
|
1682
|
-
```
|
|
1683
|
-
3. Class fields and constructor:
|
|
1684
|
-
```ts
|
|
1685
|
-
export default class TestRunner {
|
|
1686
|
-
private testFiles: string[];
|
|
1687
|
-
private run: PreparedRun;
|
|
1688
|
-
private group?: SamuraiGroup;
|
|
1689
|
-
|
|
1690
|
-
constructor(testFiles: string[], run: PreparedRun, group?: SamuraiGroup) {
|
|
1691
|
-
this.testFiles = testFiles;
|
|
1692
|
-
this.run = run;
|
|
1693
|
-
this.group = group;
|
|
1694
|
-
}
|
|
1695
|
-
```
|
|
1696
|
-
and rename the existing `public async run()` method to `public async start()` (the field `run` now holds the prepared run).
|
|
1697
|
-
4. `init`: change the signature to `static async init(run: PreparedRun, group?: SamuraiGroup)`, change `srcDir = await readConfig("srcDir");` to `srcDir = (await readConfig("srcDir")) ?? "./src";`, and return `new TestRunner(testFiles, run, group)`.
|
|
1698
|
-
5. `start()`: replace `const reporter = new TestReporter();` with
|
|
1699
|
-
```ts
|
|
1700
|
-
const reporter = new TestReporter({ environment: this.run.settings.environment });
|
|
1701
|
-
```
|
|
1702
|
-
replace `path.resolve(await readConfig("srcDir"), file)` with `path.resolve((await readConfig("srcDir")) ?? "./src", file)`, and replace the loop over `registeredTestcases` with:
|
|
1703
|
-
```ts
|
|
1704
|
-
for (const test of registeredTests()) {
|
|
1705
|
-
await this.executeTestCase(test, reporter);
|
|
1706
|
-
}
|
|
1707
|
-
```
|
|
1708
|
-
6. `executeTestCase`: replace the browser selection and timeout lines (from `let selectedBrowser` through `const timeout = Number(await readConfig("timeout"));`) with:
|
|
1709
|
-
```ts
|
|
1710
|
-
const selectedBrowser: SupportedBrowser = (await readConfig("browser")) ?? "firefox";
|
|
1711
|
-
const { settings, secrets } = this.run;
|
|
1712
|
-
const timeout = settings.timeout;
|
|
1713
|
-
```
|
|
1714
|
-
In the timeout timer, replace `(timeout || 30000) / 1000` with `timeout / 1000` and `isNaN(timeout) ? 30000 : timeout` with `timeout`. Replace `await test?.function(launched.page, launched.browser);` with:
|
|
1715
|
-
```ts
|
|
1716
|
-
const fixtures: TestFixtures = {
|
|
1717
|
-
page: launched.page,
|
|
1718
|
-
browser: launched.browser,
|
|
1719
|
-
env: createEnvFixture(settings.environment, settings.variables),
|
|
1720
|
-
secrets: createSecretsFixture(settings.environment, secrets),
|
|
1721
|
-
};
|
|
1722
|
-
await test.function(fixtures);
|
|
1723
|
-
```
|
|
1724
|
-
(`group.browser` no longer exists on `SamuraiGroup`; removing that branch is what fixes the baseline tsc errors.)
|
|
1725
|
-
|
|
1726
|
-
- [ ] **Step 7: Update `src/index.ts`**
|
|
1727
|
-
|
|
1728
|
-
```ts
|
|
1729
|
-
import { loadConfig } from "./config/config.js";
|
|
1730
|
-
import { parseRunOverrides } from "./config/run-settings.js";
|
|
1731
|
-
import { prepareRun } from "./runner/prepare-run.js";
|
|
1732
|
-
import TestRunner from "./runner/test-runner.js";
|
|
1733
|
-
|
|
1734
|
-
const run = prepareRun(await loadConfig(), parseRunOverrides(process.argv.slice(2), process.env));
|
|
1735
|
-
const runner = await TestRunner.init(run);
|
|
1736
|
-
await runner.start();
|
|
1737
|
-
|
|
1738
|
-
process.exit();
|
|
1739
|
-
```
|
|
1740
|
-
|
|
1741
|
-
- [ ] **Step 8: Public API, package exports, example test, hygiene**
|
|
1742
|
-
|
|
1743
|
-
Create `src/api.ts`:
|
|
1744
|
-
```ts
|
|
1745
|
-
export { test, describe } from "./runner/registry.js";
|
|
1746
|
-
export { expect } from "./assert/expect.js";
|
|
1747
|
-
export { defineConfig } from "./config/config.js";
|
|
1748
|
-
export type { TestFixtures } from "./types/test.js";
|
|
1749
|
-
```
|
|
1750
|
-
|
|
1751
|
-
In `package.json`, add after `"main"`:
|
|
1752
|
-
```json
|
|
1753
|
-
"exports": {
|
|
1754
|
-
".": "./src/api.ts"
|
|
1755
|
-
},
|
|
1756
|
-
```
|
|
1757
|
-
|
|
1758
|
-
In `src/tests/index.spec.ts`, replace the two imports and the test line with:
|
|
1759
|
-
```ts
|
|
1760
|
-
import { expect, test } from "../api.js";
|
|
1761
|
-
|
|
1762
|
-
test("My first test", async ({ page }) => {
|
|
1763
|
-
await page.goto("https://itmetsam.nl");
|
|
1764
|
-
```
|
|
1765
|
-
(the rest of the body is unchanged).
|
|
1766
|
-
|
|
1767
|
-
Append to `.gitignore`:
|
|
1768
|
-
```
|
|
1769
|
-
.env
|
|
1770
|
-
.env.*
|
|
1771
|
-
!.env.example
|
|
1772
|
-
```
|
|
1773
|
-
|
|
1774
|
-
Create `.env.example`:
|
|
1775
|
-
```
|
|
1776
|
-
# Copy to .env.<environment>, e.g. .env.staging. Never commit real values.
|
|
1777
|
-
# Tests read these as secrets.TEST_CARD; real environment variables win over this file.
|
|
1778
|
-
SAMURAI_SECRET_TEST_CARD=
|
|
1779
|
-
```
|
|
1780
|
-
|
|
1781
|
-
- [ ] **Step 9: Verify the package entry point**
|
|
1782
|
-
|
|
1783
|
-
Run: `npx tsx -e "import('samurai-framework').then((m) => console.log(Object.keys(m).sort().join(',')))"`
|
|
1784
|
-
Expected: `defineConfig,describe,expect,test`
|
|
1785
|
-
|
|
1786
|
-
- [ ] **Step 10: Typecheck and run every unit test**
|
|
1787
|
-
|
|
1788
|
-
Run: `npx tsc --noEmit -p .`
|
|
1789
|
-
Expected: no errors at all.
|
|
1790
|
-
|
|
1791
|
-
Run: `npm test`
|
|
1792
|
-
Expected: PASS, 0 failures.
|
|
1793
|
-
|
|
1794
|
-
- [ ] **Step 11: Run the browser tests**
|
|
1795
|
-
|
|
1796
|
-
Run: `npm run test:browser`
|
|
1797
|
-
Expected: PASS (they use `navigateTo` and `Page` directly, not the runner, so behaviour is unchanged).
|
|
1798
|
-
|
|
1799
|
-
- [ ] **Step 12: Commit**
|
|
1800
|
-
|
|
1801
|
-
```bash
|
|
1802
|
-
git add src/runner/prepare-run.ts src/runner/prepare-run.test.ts src/runner/test-runner.ts \
|
|
1803
|
-
src/wait/wait-until.ts src/wait/wait-until.test.ts src/index.ts src/api.ts package.json \
|
|
1804
|
-
.gitignore .env.example src/tests/index.spec.ts
|
|
1805
|
-
git commit -m "feat(runner): fixtures, environments and secrets in test runs
|
|
1806
|
-
|
|
1807
|
-
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
|
1808
|
-
```
|
|
1809
|
-
|
|
1810
|
-
- [ ] **Step 13: Update the roadmap**
|
|
1811
|
-
|
|
1812
|
-
In `docs/superpowers/recorder-roadmap.md`, tick item 1 (`- [x] **1. Test API alignment**`) and add ` — [spec](specs/2026-10-01-test-api-alignment-design.md)` at the end of its line. Set the spec's `Status:` line to `implemented`.
|
|
1813
|
-
|
|
1814
|
-
```bash
|
|
1815
|
-
git add docs/superpowers/recorder-roadmap.md docs/superpowers/specs/2026-10-01-test-api-alignment-design.md
|
|
1816
|
-
git commit -m "docs: mark test API alignment implemented
|
|
1817
|
-
|
|
1818
|
-
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
|
1819
|
-
```
|
|
1820
|
-
|
|
1821
|
-
Do **not** run `npm run dev` to try the example test without asking the user first: it submits a real contact form.
|