@gfdelarue/testkit 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +455 -56
  2. package/bin/testkit-windows-owner-arm64.exe +0 -0
  3. package/bin/testkit-windows-owner-x64.exe +0 -0
  4. package/dist/app/bugs/batch.js +1 -0
  5. package/dist/app/bugs/ci-report.js +2 -2
  6. package/dist/app/bugs/ci-summary.js +3 -3
  7. package/dist/app/bugs/draft.js +2 -2
  8. package/dist/app/bugs/file.js +1 -1
  9. package/dist/app/bugs/guide.js +1 -1
  10. package/dist/app/bugs/record.js +1 -1
  11. package/dist/app/bugs/run-reading.js +1 -1
  12. package/dist/app/bugs/store.js +1 -1
  13. package/dist/app/campaigns/summary.js +1 -1
  14. package/dist/app/cleanup/index.js +1 -1
  15. package/dist/app/db-schema/prepare.js +1 -1
  16. package/dist/app/db-schema/refresh.js +1 -1
  17. package/dist/app/db-schema/verify.js +1 -1
  18. package/dist/app/destroy/index.js +1 -1
  19. package/dist/app/environments/config-driven.js +1 -1
  20. package/dist/app/environments/file-setup.js +1 -1
  21. package/dist/app/environments/routing.js +1 -1
  22. package/dist/app/environments/static-values.js +1 -1
  23. package/dist/app/environments/supplied.js +1 -1
  24. package/dist/app/lint/shared.js +1 -1
  25. package/dist/app/run/live-run.js +1 -1
  26. package/dist/app/run/orchestrator.js +1 -1
  27. package/dist/app/run/planning.js +1 -1
  28. package/dist/app/run/results.js +1 -1
  29. package/dist/app/run/run-finalization.js +1 -1
  30. package/dist/app/run/run-finisher.js +1 -0
  31. package/dist/app/run/setup/runtime-node-setup-protocol.js +1 -1
  32. package/dist/app/run/setup/runtime-node-setup-worker.js +1 -1
  33. package/dist/app/run/setup/runtime-node-setup.js +2 -2
  34. package/dist/app/status/runs.js +1 -1
  35. package/dist/app/stop/index.js +1 -1
  36. package/dist/app/tracker/index.js +1 -1
  37. package/dist/app/tracker/token.js +2 -2
  38. package/dist/app/typecheck/index.js +4 -4
  39. package/dist/cli/command-catalog.js +1 -1
  40. package/dist/cli/command-flags.js +1 -1
  41. package/dist/cli/commands/bug/file.js +3 -2
  42. package/dist/cli/framework/help.js +5 -5
  43. package/dist/cli/framework/parser.js +1 -1
  44. package/dist/cli/json-errors.js +1 -1
  45. package/dist/cli/renderers/bug/text.js +1 -1
  46. package/dist/cli/renderers/run/formatting.js +1 -1
  47. package/dist/config/loader/config-loader.js +1 -1
  48. package/dist/config/loader/discover-tests.js +1 -1
  49. package/dist/config/loader/discovery-result.js +1 -0
  50. package/dist/config/loader/index.js +1 -1
  51. package/dist/config/loader/runtime-isolation.js +1 -1
  52. package/dist/config/loader/skip-config.js +1 -1
  53. package/dist/config/loader/test-selection.js +1 -1
  54. package/dist/database/admin.js +5 -2
  55. package/dist/database/call-caps.js +1 -0
  56. package/dist/database/cleanup.js +1 -1
  57. package/dist/database/configured-steps.js +1 -1
  58. package/dist/database/constants.js +1 -1
  59. package/dist/database/docker-endpoint.js +1 -0
  60. package/dist/database/docker-metadata.js +2 -2
  61. package/dist/database/docker.js +5 -5
  62. package/dist/database/index.js +2 -1
  63. package/dist/database/lifecycle-cleanup.js +1 -1
  64. package/dist/database/local-postgres.js +4 -1
  65. package/dist/database/naming.js +1 -1
  66. package/dist/database/operation-deadline.js +1 -0
  67. package/dist/database/postgres/connection-check.js +1 -1
  68. package/dist/database/postgres/index.js +2 -2
  69. package/dist/database/postgres/owned-exec.js +27 -0
  70. package/dist/database/postgres/protocol.js +2 -2
  71. package/dist/database/provider.js +1 -0
  72. package/dist/database/roles.js +2 -2
  73. package/dist/database/schema-operations.js +5 -1
  74. package/dist/database/schema-source.js +3 -3
  75. package/dist/database/steps.js +1 -1
  76. package/dist/database/template-steps.js +4 -4
  77. package/dist/domain/suite-selection.js +1 -1
  78. package/dist/engines/adapters/evidence-files.js +1 -1
  79. package/dist/engines/adapters/execution.js +1 -1
  80. package/dist/engines/node-vitest/adapter/adapter.js +1 -1
  81. package/dist/engines/node-vitest/adapter/driver.js +2 -2
  82. package/dist/engines/node-vitest/runtime/runtime-input.js +1 -1
  83. package/dist/engines/playwright/adapter/adapter.js +1 -1
  84. package/dist/engines/playwright/adapter/browser-network-evidence.js +4 -0
  85. package/dist/engines/playwright/adapter/config.js +16 -6
  86. package/dist/engines/playwright/adapter/evidence.js +2 -1
  87. package/dist/engines/playwright/adapter/failure-details.js +2 -2
  88. package/dist/engines/playwright/adapter/reporter.js +1 -1
  89. package/dist/engines/playwright/runtime/browser-network.js +1 -0
  90. package/dist/engines/playwright/runtime/browser-traffic.js +1 -0
  91. package/dist/engines/playwright/runtime/chromium-environment.js +1 -0
  92. package/dist/engines/playwright/runtime/emulation.js +1 -0
  93. package/dist/engines/playwright/runtime/index.js +1 -1
  94. package/dist/engines/playwright/runtime/managed-test.js +1 -1
  95. package/dist/engines/playwright/runtime/runtime-input.js +2 -2
  96. package/dist/environment/lifecycle-controller.js +1 -1
  97. package/dist/environment/lifecycle.js +1 -1
  98. package/dist/environment/maintenance.js +1 -1
  99. package/dist/environment/run-environment.js +1 -1
  100. package/dist/environment/run-stopping.js +1 -1
  101. package/dist/environment/runtime-manager.js +1 -1
  102. package/dist/evidence/artifacts.js +5 -2
  103. package/dist/evidence/bundles.js +2 -1
  104. package/dist/evidence/compressed-artifacts.js +1 -0
  105. package/dist/evidence/environment-record.js +1 -1
  106. package/dist/evidence/index.js +1 -1
  107. package/dist/evidence/live-run-summary.js +1 -0
  108. package/dist/evidence/local-paths.js +1 -0
  109. package/dist/evidence/maintenance.js +1 -1
  110. package/dist/evidence/operation-timings.js +1 -1
  111. package/dist/evidence/redaction.js +4 -4
  112. package/dist/evidence/run-evidence.js +1 -0
  113. package/dist/evidence/setup-progress.js +1 -1
  114. package/dist/evidence/storage-inventory.js +1 -1
  115. package/dist/evidence/storage.js +2 -2
  116. package/dist/kernel/redaction-targets.js +1 -1
  117. package/dist/kernel/redaction.js +7 -4
  118. package/dist/process/engine-environment-keys.js +1 -0
  119. package/dist/process/environment-overlay.js +1 -1
  120. package/dist/process/environment.js +1 -1
  121. package/dist/process/execa-bundle.json +3 -3
  122. package/dist/process/files/evidence-writes.js +1 -0
  123. package/dist/process/files/product-dir.js +1 -1
  124. package/dist/process/host-process.js +1 -1
  125. package/dist/process/index.js +1 -1
  126. package/dist/process/lifecycle-fence.js +1 -1
  127. package/dist/process/lock-progress.js +2 -0
  128. package/dist/process/locks.js +4 -4
  129. package/dist/process/process-identity.js +1 -1
  130. package/dist/process/process.js +35 -35
  131. package/dist/process/recovery.js +1 -1
  132. package/dist/process/runs/manifest.js +1 -1
  133. package/dist/process/runs/ownership.js +1 -1
  134. package/dist/process/runs/stop.js +1 -1
  135. package/dist/process/shutdown.js +1 -1
  136. package/dist/process/windows-native.js +1 -1
  137. package/dist/repo/history.js +7 -4
  138. package/dist/repo/managed-git.js +1 -1
  139. package/dist/repo/state.js +4 -4
  140. package/dist/steps/environment.js +1 -1
  141. package/dist/toolchains/node-provisioning.js +2 -2
  142. package/dist/tracker/gitea.js +1 -1
  143. package/dist/tracker/github.js +1 -1
  144. package/dist/tracker/http.js +1 -1
  145. package/dist/tracker/ownership.js +1 -0
  146. package/dist/tracker/redaction.js +1 -1
  147. package/dist/tracker/responses.js +1 -1
  148. package/dist/types/playwright.d.ts +21 -0
  149. package/dist/types/workspace.d.ts +4 -4
  150. package/docs/bug-records.md +16 -10
  151. package/docs/engines.md +52 -4
  152. package/docs/release-notes.md +730 -0
  153. package/package.json +14 -2
  154. package/testkit-config-schema.md +36 -42
  155. package/dist/config/loader/task-policy.js +0 -1
  156. package/dist/environment/lifecycle-artifacts.js +0 -1
package/README.md CHANGED
@@ -50,13 +50,18 @@ whose empty selection is intentional; it should not be the default CI command.
50
50
 
51
51
  Testkit writes the immutable, verifiable run bundle under
52
52
  `.testkit/results/runs/<run-id>/`. Upload `.testkit/results` with `if: always()`
53
- when CI evidence should survive a failed job.
53
+ when CI evidence should survive a failed job. If the job can kill Testkit before
54
+ it seals the run, run `testkit cleanup` before the upload, so the run is sealed
55
+ and redacted first. See
56
+ [Read run evidence without the CLI](#read-run-evidence-without-the-cli).
54
57
 
55
58
  ## Report a run to GitHub or Gitea
56
59
 
57
60
  `testkit ci report` reads the last run in the checkout and posts it in three
58
61
  places: one commit status, the job's step summary, and one pull request comment
59
- that it updates in place.
62
+ that it updates in place. It updates only a comment that its token's user
63
+ wrote, and posts a new comment when the tracker won't say who that user is. It
64
+ leaves out a CI run or issue link that holds credentials or a secret.
60
65
 
61
66
  ```yaml
62
67
  permissions:
@@ -80,10 +85,17 @@ steps:
80
85
  it, as the `env` lines above do. Gitea Actions accepts the same lines.
81
86
  - Testkit reads the token from `GITHUB_TOKEN` or `GH_TOKEN` for GitHub, and
82
87
  from `GITEA_TOKEN` for Gitea. In a Gitea Actions job it also reads
83
- `GITHUB_TOKEN`. With no token set, it asks `git credential fill`. The token
84
- that `actions/checkout` keeps in the checkout does not answer that request.
85
- - Testkit reaches GitHub Enterprise Server only from a GitHub Actions job on
86
- that server, so `testkit bug file` works there only in a job.
88
+ `GITHUB_TOKEN`. Outside such a job, it sends `GITEA_TOKEN` only to the host
89
+ that `GITEA_SERVER_URL` names, such as `https://gitea.example.com`. With no
90
+ token set, it asks `git credential fill`. The token that `actions/checkout`
91
+ keeps in the checkout does not answer that request.
92
+ - Testkit sends a token only over HTTPS. A plain `http://` remote or repository
93
+ URL works only for a loopback host such as `127.0.0.1`.
94
+ - A job's token reaches only the job's own repository. When `bugs.repository`
95
+ names another one, give the step a token that may write to it.
96
+ - Testkit reaches GitHub Enterprise Server, and GitHub Enterprise Cloud with
97
+ data residency (`*.ghe.com`), only from a GitHub Actions job on that host, so
98
+ `testkit bug file` works there only in a job.
87
99
  - GitHub gives a pull request from a fork a read-only token. `ci report` then
88
100
  writes the step summary and fails when it posts the status. Add
89
101
  `continue-on-error: true` to the step when the repository takes pull requests
@@ -99,7 +111,7 @@ steps:
99
111
  Testkit files a confirmed bug as an issue in the repository's GitHub or Gitea.
100
112
  The issue names the regression test that fails because of the bug, the commit
101
113
  that introduced the faulty lines, and the command that reproduces the failure.
102
- [Bug records](docs/bug-records.md) gives the issue format.
114
+ [`docs/bug-records.md`](https://app.unpkg.com/@gfdelarue/testkit/files/docs/bug-records.md) gives the issue format.
103
115
 
104
116
  1. Write a Testkit test that fails because of the bug.
105
117
  2. Write the bug record as a JSON file. `testkit bug guide` prints the fields
@@ -111,8 +123,15 @@ that introduced the faulty lines, and the command that reproduces the failure.
111
123
 
112
124
  `testkit bug file` runs the test at the checked-out commit and files the issue
113
125
  only when the test fails. It stops when tracked files have uncommitted changes,
114
- when no remote branch contains the commit, or when an open issue already covers
115
- the test. The token needs permission to create issues and labels.
126
+ when no branch on the remote contains the commit, or when an open issue already
127
+ covers the test. It asks the remote which branches hold the commit, with the
128
+ checkout's Git credentials, so a branch that was rewound or deleted there
129
+ doesn't count. When it can't tell, because a remote doesn't answer or a shallow
130
+ clone's history stops short, it stops and says why. After it files, it reads the open issues again. If another checkout
131
+ filed the same test at the same time and its issue is older, Testkit closes its
132
+ own issue and stops, naming the older one. GitHub can list a new issue a few
133
+ seconds late, so Testkit waits up to 7.5 seconds for its own and says so when
134
+ it doesn't appear. The token needs permission to create issues and labels.
116
135
 
117
136
  `testkit bug record` saves the `--vitest-config`, `--vitest-project`, and
118
137
  `--vitest-runner` it was given in the draft, and asks for `--vitest-project`
@@ -131,6 +150,38 @@ fragment. It refuses `--database` and `--env`; pass those values through
131
150
  root, so a relative `--environment-file` needs `testkit bug file` run from
132
151
  there too.
133
152
 
153
+ `testkit bug file --batch` files several drafts in one call: the ids it is
154
+ given, or every unfiled draft under `.testkit/bugs/` when it is given none.
155
+
156
+ - It checks the checkout once and reads the open issues before it runs the
157
+ tests. It stops before it files anything when tracked files have uncommitted
158
+ changes, when no remote branch contains the commit, or when it cannot read
159
+ the open issues.
160
+ - It runs the drafts' tests in one `testkit run`, or one run for each
161
+ `--vitest-config`, `--vitest-project`, and `--vitest-runner` the drafts were
162
+ recorded with. Each draft is judged by its own file's result, so a file that
163
+ is skipped or fails outside its tests rules out only its own draft.
164
+ - It reads the open issues again before it files, in case another checkout
165
+ filed one of the tests meanwhile.
166
+ - It reports one line per draft: filed, already filed, rejected with the
167
+ reason, or not filed.
168
+ - A draft is rejected when its own checks fail, for example when its test
169
+ passes or an open issue already covers the test, and the batch carries on.
170
+ - When the tracker refuses a request, the batch stops filing and reports the
171
+ drafts it has not reached as not filed.
172
+ - It exits non-zero when any draft is rejected or not filed. `--json` returns
173
+ the same list.
174
+
175
+ Each draft records its issue as soon as the tracker returns it, so running the
176
+ batch again files only what is left. An open issue whose facts match the ones a
177
+ draft would file counts as that draft's issue, so a batch stopped between the
178
+ two steps does not file the bug twice. When the tracker creates an issue
179
+ without its labels, the batch stops, the draft stays unfiled, and Testkit
180
+ closes that issue. If it can't, the message says so, and you close the issue.
181
+ Then run the batch again with a token that may label issues. The batch creates
182
+ at most one issue a second. When the tracker limits the rate, it stops and
183
+ says when to run it again.
184
+
134
185
  ### Name the product's areas
135
186
 
136
187
  Add `areas` to `testkit.config.ts` so that each issue names the part of the
@@ -147,7 +198,7 @@ export default defineConfig({
147
198
 
148
199
  `testkit bug file` puts an `area:<id>` label on the issue. If no area claims
149
200
  the fault's file, it stops and names the file. Without `areas`, Testkit files
150
- bugs with no area. See [`areas`](testkit-config-schema.md#areas).
201
+ bugs with no area. See the `areas` section of [`testkit-config-schema.md`](https://app.unpkg.com/@gfdelarue/testkit/files/testkit-config-schema.md).
151
202
 
152
203
  ### Pass a run whose failures are all filed
153
204
 
@@ -163,7 +214,8 @@ export default defineConfig({
163
214
  A run then passes when an open issue with the `testkit` label covers each
164
215
  failure. A new failure still fails the run, and closing an issue makes its
165
216
  test count again. When Testkit cannot read the issues, every failure is new.
166
- [Known failures](docs/bug-records.md#known-failures) gives the exact rule.
217
+ The Known failures section of [`docs/bug-records.md`](https://app.unpkg.com/@gfdelarue/testkit/files/docs/bug-records.md) gives the
218
+ exact rule.
167
219
 
168
220
  With `"allow"`, the run reads the open issues. In CI, pass the job's token to
169
221
  the `testkit run` step as well:
@@ -175,7 +227,9 @@ steps:
175
227
  GITHUB_TOKEN: ${{ github.token }}
176
228
  ```
177
229
 
178
- The job also needs the `issues: read` permission.
230
+ The job also needs the `issues: read` permission. Testkit reads
231
+ `GITHUB_TOKEN`, `GH_TOKEN` and `GITEA_TOKEN` itself and doesn't pass them to
232
+ services or setup steps. A service that needs one declares it in its `env`.
179
233
 
180
234
  ### Run a campaign
181
235
 
@@ -293,10 +347,10 @@ npx @gfdelarue/testkit setup playwright --with-deps # CI and fresh hosts
293
347
  ```
294
348
 
295
349
  Normal runs never install or download a browser.
296
- [Installation](docs/engines.md#installation) covers what an install contains,
297
- the supported platforms, and how to recover a broken one.
298
- [Typechecking](docs/engines.md#typechecking) covers the supported TypeScript
299
- versions and `tsconfig.playwright.json`.
350
+ The Installation section of [`docs/engines.md`](https://app.unpkg.com/@gfdelarue/testkit/files/docs/engines.md) covers
351
+ what an install contains, the supported platforms, and how to recover a broken
352
+ one. Its Typechecking section covers the supported TypeScript versions and
353
+ `tsconfig.playwright.json`.
300
354
 
301
355
  Testkit bounds the output it captures from each command it runs, and asks the
302
356
  command and its descendants to stop on cancellation or timeout.
@@ -314,19 +368,20 @@ reserves `APPDATA`, `ComSpec`, `HOME`, `LANG`, `LC_ALL`, `LOCALAPPDATA`, `PATH`,
314
368
 
315
369
  - Node/Vitest reserves `BASE_URL`, `FORCE_COLOR`, `NODE_OPTIONS`, `NO_COLOR`,
316
370
  `UPDATE_SNAPSHOT`, `VITEST_POOL_ID`, `VITEST_WORKER_ID`, and the `TESTKIT_`
317
- keys it sets itself, listed in
318
- [Environment](docs/engines.md#environment);
371
+ keys it sets itself, listed in the Environment section of
372
+ [`docs/engines.md`](https://app.unpkg.com/@gfdelarue/testkit/files/docs/engines.md);
319
373
  other `TESTKIT_` and `VITEST_` keys, such as
320
374
  `TESTKIT_REQUIRE_COMPLETE_SUITE`, pass through, though Vitest's own `TEST`,
321
375
  `VITEST`, `VITEST_MODE`, `FORCE_TTY`, `MODE`, `DEV`, `PROD`, and `SSR`
322
376
  replace a group's values;
323
377
  - Playwright reserves the `TESTKIT_`, `PLAYWRIGHT_`, `PWTEST_`, and `PW_TEST_`
324
- prefixes plus `BASE_URL`, `FORCE_COLOR`, `NODE_OPTIONS`, and `NO_COLOR`.
378
+ prefixes plus `BASE_URL`, `DBUS_SESSION_BUS_ADDRESS`, `FC_FONTATIONS`,
379
+ `FORCE_COLOR`, `NODE_OPTIONS`, and `NO_COLOR`.
325
380
 
326
381
  Case-variant duplicates and invalid names or NUL-containing values fail instead
327
382
  of being normalized. Node/Vitest accepts at most 512 entries and
328
383
  4 MiB of UTF-8 key/value data. Playwright accepts at most 512 entries and 2 MiB
329
- of UTF-8 key/value data. [Environment](docs/engines.md#environment) lists the
384
+ of UTF-8 key/value data. The Environment section of [`docs/engines.md`](https://app.unpkg.com/@gfdelarue/testkit/files/docs/engines.md) lists the
330
385
  complete limits, and [Named endpoints and databases](#named-endpoints-and-databases)
331
386
  covers the environment API.
332
387
 
@@ -402,7 +457,7 @@ which `taskConcurrency` doesn't limit.
402
457
  `execution.workers`, `presets.nodeNext({ workers })` and `--workers` are
403
458
  deprecated aliases. When both names are
404
459
  provided in config or on the CLI, their values must match. The CLI value overrides
405
- config after those checks. See the [execution settings](testkit-config-schema.md#execution).
460
+ config after those checks. See the `execution` section of [`testkit-config-schema.md`](https://app.unpkg.com/@gfdelarue/testkit/files/testkit-config-schema.md).
406
461
 
407
462
  Batch `run` output stays intentionally short: one line per completed file,
408
463
  concise failure blocks, and a final summary. Service logs, captured runtime
@@ -498,7 +553,7 @@ The environment file is JSON:
498
553
  "handles": {
499
554
  "webhook": {
500
555
  "kind": "http-capture",
501
- "url": "http://127.0.0.1:41000/webhook",
556
+ "url": "http://127.0.0.1:41000/__testkit__/<segment>/webhook",
502
557
  "requestsUrl": "http://127.0.0.1:41000/__testkit__/<capability>/requests",
503
558
  "resetUrl": "http://127.0.0.1:41000/__testkit__/<capability>/requests"
504
559
  }
@@ -589,7 +644,7 @@ assertLocalDatabaseUrl(process.env, "seed.ts");
589
644
 
590
645
  ## Setup
591
646
 
592
- See the [configuration schema](testkit-config-schema.md) for the complete,
647
+ See [`testkit-config-schema.md`](https://app.unpkg.com/@gfdelarue/testkit/files/testkit-config-schema.md) for the complete,
593
648
  strict `testkit.config.ts` contract.
594
649
 
595
650
  Create `testkit.config.ts` at repo root:
@@ -675,7 +730,7 @@ neither:
675
730
  import { defineFile } from "@gfdelarue/testkit";
676
731
 
677
732
  export const testkit = defineFile({
678
- engine: "node-vitest",
733
+ engine: "playwright",
679
734
  skip: "Billing is currently unavailable locally",
680
735
  });
681
736
  ```
@@ -734,6 +789,10 @@ it. Database changes for one product run one at a time; other products don't
734
789
  wait for them. Use `cleanup --resources --dry-run` to preview the selected
735
790
  product's cleanup.
736
791
 
792
+ A run's start and its post-run maintenance don't list or reclaim Docker
793
+ resources. A run stops its retained database by name when it releases the run's
794
+ resources.
795
+
737
796
  `database.template` is the database-side equivalent for reusable template DB
738
797
  state. When `database.sourceSchema` is configured, Testkit treats the configured
739
798
  source database as the schema source of truth. A normal `testkit run` resolves a
@@ -844,6 +903,8 @@ services: {
844
903
  }
845
904
  ```
846
905
 
906
+ ## Run evidence
907
+
847
908
  Every terminal run is sealed under `.testkit/results/runs/<run-id>/` with a
848
909
  canonical `run.json`, `provenance.json`, `manifest.json`, and the run's exact
849
910
  artifact/log/setup files. Payloads are SHA-256 addressed under
@@ -869,25 +930,56 @@ workspace API.
869
930
  Sealing replaces secrets with `<redacted>` in the run record and in every text
870
931
  file of the bundle. A key names a secret when it holds `PASSWORD`, `TOKEN`,
871
932
  `SECRET`, `PRIVATE_KEY`, `AUTH`, `COOKIE`, `SESSION`, or `API_KEY` in any letter
872
- case. Sealing redacts:
933
+ case, or when its last word is `PASS`, `PASSWD`, `PAT`, `DSN`, `CREDENTIAL`, or
934
+ `CREDENTIALS`. `AUTH` doesn't count in `AUTHOR` or `AUTHORITY`, so
935
+ `GIT_AUTHOR_NAME` names no secret, though `AUTHORIZATION` still does. `SESSION`
936
+ doesn't count before `COUNT`, `DURATION`, `EXPIR`, `LENGTH`, `LIMIT`, `MAX`,
937
+ `TIMEOUT`, or `TTL`, as in `sessionCount` and `SESSION_TIMEOUT`. A key whose
938
+ last two words are `API`, `PRIVATE`, `SIGNING`, or `ENCRYPTION` and then `KEY`,
939
+ as in `apiKey`, `x-api-key`, and `privateKey`, names a secret too, and so does
940
+ an upper-case key whose last word is `KEY`, such as `STRIPE_KEY`, unless the
941
+ word before it is `PUBLIC` or `PUBLISHABLE`. Words break at any character other
942
+ than a letter or a digit, and before an upper-case letter that follows a
943
+ lower-case letter or a digit. Sealing redacts:
873
944
 
874
945
  - A `KEY=value` assignment under such a key, an `Authorization`,
875
- `Proxy-Authorization`, `Cookie`, or `Set-Cookie` header value, a JSON string
876
- value under such a key, even when the key is spelled with escapes, a
877
- single-quoted `'password': '…'` value, and a URL's password, wherever they
878
- appear. A quoted value ends at its closing quote and keeps its quotes, and a
879
- value that starts with an auth scheme such as `Bearer` ends with its line.
880
- A secret in one JSON value leaves the record's other values as they are.
946
+ `Proxy-Authorization`, `Cookie`, `Set-Cookie`, `Api-Key`, or `X-Api-Key`
947
+ header value, a JSON string value under such a key, even when the key is
948
+ spelled with escapes, a single-quoted string value under such a key, a token
949
+ of at least eight characters with a digit after `Bearer`, and a URL's
950
+ password, wherever they appear. A
951
+ quoted value ends at its closing quote and keeps its quotes, and a value that
952
+ starts with an auth scheme such as `Bearer` ends with its line. A secret in
953
+ one JSON value leaves the record's other values as they are.
954
+ - Every string in a JSON array or object under such a key, unless the key
955
+ names a secret only through `AUTH`, `COOKIE`, or `SESSION`, so `tokens`,
956
+ `credentials`, and `privateKey` count and `session` and `auth` settings stay.
957
+ In an object with a `name` string and a `value` string, only the `value` and
958
+ members whose own key names a secret count. Numbers and booleans stay.
959
+ - The value after `password:` or `passwd:`, or after a key whose last word is
960
+ one of those: a quoted value anywhere, as in `{ password: 'hunter2' }`, and
961
+ an unquoted value at the start of a line, as in YAML, when it is the line's
962
+ one word. A value that is one word of letters, perhaps followed by
963
+ punctuation such as `!` or `.`, stays, and so does an unquoted value after
964
+ other text, so `password: 'Required'`, `password: required!`, and
965
+ `rejects a password: too short` stay.
966
+ - A token of at least 20 characters with a digit that stands alone as the user
967
+ of an `http` or `https` URL, as in `https://ghp_…@github.com`. A URL's
968
+ password may hold `/` and `@`, and sealing also finds it in a directory name,
969
+ which keeps one slash of the URL, as in `postgres:/app:…@db`.
970
+ - A header or cookie stored as a `{"name": …, "value": …}` pair, as Playwright
971
+ traces and HAR files store them: the value when the name names a secret, and
972
+ every value in a `cookies` array.
881
973
  - The value of each such key, and of each key that `redact` names, in the
882
- environment Testkit runs in. When the value is a URL, its password counts
883
- too.
974
+ environment Testkit runs in. When the value is a URL, its password, or a
975
+ token that stands alone as its user, counts too.
884
976
  - The values the run's environment resolved: each value under such a key or a
885
- key that `redact` names, and the password of each URL. A PostgreSQL URL's
886
- password may also be a `password` query parameter, as libpq reads it. This
887
- covers config `env`, env files, `local.env`, dependency services, base,
888
- readiness, UI, and database URLs, `--env`, `--env-from`, and an
889
- `--environment-file`. A value under such a key that is a URL counts only for
890
- its password.
977
+ key that `redact` names, and the password or token user of each URL. A
978
+ PostgreSQL URL's password may also be a `password` query parameter, as libpq
979
+ reads it. This covers config `env`, env files, `local.env`, dependency
980
+ services, base, readiness, UI, and database URLs, `--env`, `--env-from`, and
981
+ an `--environment-file`. A value under such a key that is a URL counts only for
982
+ its password or token user.
891
983
  - The unguessable segment in each [HTTP capture's](#handles) operation URLs,
892
984
  from a setup module's capture or an `--environment-file` handle.
893
985
 
@@ -909,12 +1001,41 @@ and keys in the free-form data and metadata that tests attach. When output cuts
909
1001
  the cut with `…` or `...`, sealing redacts the part that shows. `testkit run
910
1002
  --json` and the run's progress output apply the same redaction.
911
1003
 
1004
+ Sealing also redacts inside `.zip` and `.gz` files, and files that start like
1005
+ one, such as a Playwright `trace.zip`. It applies the rules above to each text
1006
+ entry, such as a trace's actions, network log, and page and API responses, and
1007
+ writes the archive again with the redacted entries. A text entry is valid UTF-8
1008
+ without binary control characters, or UTF-16 with a byte order mark. Other
1009
+ entries keep their bytes. A ZIP with a comment or extra fields is written again
1010
+ without them, and a gzip file is always written again without the name and
1011
+ comment in its header. A trace stays readable in the Playwright trace viewer,
1012
+ with `<redacted>` where a typed password or a token was.
1013
+
1014
+ Sealing refuses an archive it can't redact:
1015
+
1016
+ - one it can't read: encrypted, needing ZIP64 sizes, compressed with a method
1017
+ other than deflate, larger than 512 MiB once expanded, or damaged, including
1018
+ a ZIP with bytes that none of its entries account for;
1019
+ - one with a binary entry, such as a screenshot, that holds one of the values
1020
+ above, as UTF-8 or UTF-16;
1021
+ - one that holds another archive.
1022
+
1023
+ Sealing removes each file it refuses from the run's results and fails the run
1024
+ with the reason, for example:
1025
+
1026
+ ```text
1027
+ Refusing to archive compressed file whose entry inner/trace.zip is itself an archive, and removed it: …/.testkit/results/artifacts/frontend/task-1-01-bundle.zip
1028
+ ```
1029
+
1030
+ The run, or the `testkit stop`, `testkit cleanup` or next run that seals it
1031
+ later, then seals the rest of its results and records the reason.
1032
+
912
1033
  Some secrets stay visible:
913
1034
 
914
1035
  - What a setup module prints goes to the terminal as written.
915
- - Sealing does not look inside compressed or binary files, such as a Playwright
916
- `trace.zip`. It refuses to seal a binary file that holds one of the values
917
- above in plain bytes.
1036
+ - Sealing does not redact images or video, including the screenshots in a
1037
+ trace. Text typed into a page shows in them. Sealing refuses and removes a
1038
+ binary file that holds one of the values above as UTF-8 or UTF-16.
918
1039
  - When a run's process is killed before it seals the run, `testkit stop`,
919
1040
  `testkit cleanup`, or the next run seals it from another process. That
920
1041
  process does not have the values the run's environment resolved or the
@@ -922,6 +1043,15 @@ Some secrets stay visible:
922
1043
  values under the words above. The bundle's `archive.redaction.status` is then
923
1044
  `source-targets-unavailable` instead of `applied`.
924
1045
 
1046
+ Sealing also takes out local paths. In the run record, in every text file of
1047
+ the bundle, and in every text entry of a compressed file, a path under the
1048
+ product directory becomes relative to it, the product directory itself becomes
1049
+ `.`, and a path under your home directory starts with `~`. This covers engine
1050
+ reports, failure stacks, logs, and Playwright traces, and happens after
1051
+ redaction. Binary files and binary entries keep their paths. When sealing
1052
+ changes a file, the file's size and SHA-256 in `run.json` describe the sealed
1053
+ file.
1054
+
925
1055
  Use the artifact archive config to opt into pruning or atomic publication to
926
1056
  an external directory. Local retention is indefinite by default:
927
1057
 
@@ -963,6 +1093,73 @@ Programmatic consumers can import `listRunBundles`, `loadRunBundleArtifact`,
963
1093
  `loadRunBundleManifest`, and `verifyRunBundle` from
964
1094
  `@gfdelarue/testkit/workspace`.
965
1095
 
1096
+ ### Read run evidence without the CLI
1097
+
1098
+ Run evidence is plain files, so you can investigate a run with `ls`, `grep`,
1099
+ `jq`, and `sha256sum` when `testkit` can't start, for example because the
1100
+ config no longer loads. A command that fails to load the config stops before it
1101
+ writes to `.testkit/results`, and `testkit artifacts` doesn't need the config.
1102
+
1103
+ | Path under `.testkit/results/` | What it holds |
1104
+ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1105
+ | `runs/<run-id>/run.json` | A sealed run's record: `run.status`, `run.interruptReason`, and each file's `status`, `failureDetails`, and `artifacts` |
1106
+ | `runs/<run-id>/manifest.json` | Every other file in the bundle, with its `sha256` and `bytes` |
1107
+ | `runs/<run-id>/provenance.json` | The run's CI, source, runtime, and redaction facts |
1108
+ | `runs/<run-id>/files/` | The run's `logs/`, `artifacts/`, and `setup/` files. Each `artifacts[].path` in `run.json` points here, relative to the product directory |
1109
+ | `latest.json` | The record of the last run that Testkit finished writing up, with its publication and retention results. A run killed after it was sealed can be in `runs/` but not here, so `runs/<run-id>/run.json` is the authority |
1110
+ | `live.json` | The record of a run that isn't sealed, with `run.status` `running` |
1111
+ | `logs/`, `artifacts/`, `setup/` | The files that unsealed run has written so far |
1112
+
1113
+ Anything else under `.testkit/` can change between releases, including
1114
+ `objects/`, which stores the bytes behind each bundle's files.
1115
+
1116
+ A run is sealed once `runs/<run-id>/` exists. Testkit writes the bundle under a
1117
+ hidden name in `runs/`, writes `manifest.json` last, and then renames the
1118
+ directory, so `runs/<run-id>/` is never partly written. Ignore names in `runs/`
1119
+ that start with `.`. A copy of a bundle, such as a CI upload, is whole when
1120
+ every file that its `manifest.json` lists has the listed `sha256`. A sealed
1121
+ `run.json` has `archive.status` `complete` and `run.status` `passed`, `failed`,
1122
+ or `interrupted`.
1123
+
1124
+ `live.json` means a run is in progress, or its process died before Testkit
1125
+ finished with it. If `runs/<run-id>/` exists for the `run.id` in `live.json`,
1126
+ the run was sealed and the bundle is its record: `live.json` and the active
1127
+ directories are leftovers, which the next `testkit run`, `cleanup`, or `destroy`
1128
+ removes. Testkit replaces `live.json` whole on each update, so it always parses.
1129
+ Its `generatedAt` is the time of the last update, and each file's `status` is
1130
+ what it was then, so a file that was still running shows `not_run`. The files
1131
+ in `logs/`, `artifacts/`, and `setup/` aren't redacted until the run is sealed.
1132
+
1133
+ How an unfinished run gets sealed:
1134
+
1135
+ - When Ctrl-C, `SIGTERM`, or `testkit stop` interrupts the run, or its process
1136
+ exits early, Testkit seals it straight away. `run.status` is `interrupted`,
1137
+ or `failed` when nothing asked the run to stop, and `run.interruptReason`
1138
+ says why.
1139
+ - When the process is killed with `SIGKILL`, by the out-of-memory killer, or
1140
+ with its host, the run stays unsealed until the next `testkit run`,
1141
+ `cleanup`, or `destroy` seals it, before doing anything else, with
1142
+ `run.status` `interrupted` and `run.interruptReason`
1143
+ `stale run process not active`. `testkit stop` seals it too, with its
1144
+ `--reason` or `stop requested`. The sealed `run.finishedAt` is when the
1145
+ command sealed it, not when the process died.
1146
+
1147
+ Testkit keeps what reached the disk. It can't keep output that a process hadn't
1148
+ written yet, anything written after the disk filled up or became read-only, or
1149
+ work that a killed process would have done on its way out. A log can end
1150
+ mid-line.
1151
+
1152
+ For example, to see what failed in the run that `latest.json` names and check
1153
+ its files:
1154
+
1155
+ ```sh
1156
+ cd .testkit/results
1157
+ run=runs/$(jq -r .run.id latest.json)
1158
+ jq '.run.status, .run.interruptReason' "$run/run.json"
1159
+ jq '.groups[].suites[].files[] | select(.status != "passed") | {path, status, failureDetails, artifacts: [.artifacts[].path]}' "$run/run.json"
1160
+ jq -r '.files[] | "\(.sha256) \(.path)"' "$run/manifest.json" | (cd "$run" && sha256sum --check --quiet)
1161
+ ```
1162
+
966
1163
  ## Authoring
967
1164
 
968
1165
  ### Named endpoints and databases
@@ -1044,6 +1241,14 @@ run's results, artifacts, and events, and the capture refuses any other
1044
1241
  operation. When Testkit releases the file's environment, the capture closes and
1045
1242
  the handle expires. Calling a handle never starts a sidecar or a service.
1046
1243
 
1244
+ The capture's `url` holds a second unguessable segment, which Testkit also
1245
+ keeps out of the run's records. The capture records a request only when its
1246
+ path starts with that segment, and answers any other path with 404. A captured
1247
+ request's `path` leaves the segment out: a request to `url` records `/webhook`,
1248
+ and one to `url` + `/42` records `/webhook/42`. The capture answers 403 to a
1249
+ request whose `Host` header isn't `127.0.0.1:<port>`, which keeps a web page
1250
+ from reaching it through DNS rebinding.
1251
+
1047
1252
  - A name with no handle throws `EnvironmentValueMissingError` with `kind`
1048
1253
  `"handle"`, and the test ends as an infrastructure failure, as for a missing
1049
1254
  endpoint.
@@ -1068,7 +1273,8 @@ test, in any order.
1068
1273
  so read them and leave them unchanged.
1069
1274
  - When the test ends, leave its rows in place. Testkit resets the databases it
1070
1275
  provisions. By default, every run starts from a fresh clone of the template
1071
- database. See [`database.reset`](testkit-config-schema.md#database).
1276
+ database. See `database.reset` in the `database` section of
1277
+ [`testkit-config-schema.md`](https://app.unpkg.com/@gfdelarue/testkit/files/testkit-config-schema.md).
1072
1278
  - Clean up only what Testkit doesn't own, such as an account in a third-party
1073
1279
  sandbox. Register the cleanup as soon as the test creates the account. Give
1074
1280
  the account a unique name, so that a leftover from a cleanup that never ran
@@ -1125,8 +1331,24 @@ Files that name the same lock never run at the same time within one run.
1125
1331
  Testkit doesn't share locks between runs, such as two CI jobs. Two tests that
1126
1332
  need a lock because they share rows need their own rows instead.
1127
1333
 
1334
+ A plain Vitest file names its locks in its group's `requirements`:
1335
+
1128
1336
  ```ts
1129
- export const testkit = defineFile({ engine: "node-vitest", locks: ["payments-sandbox-webhook"] });
1337
+ groups: {
1338
+ payments: {
1339
+ roots: ["src/payments"],
1340
+ uses: ["api"],
1341
+ requirements: {
1342
+ files: [{ path: "src/payments/webhook.test.ts", locks: ["payments-sandbox-webhook"] }],
1343
+ },
1344
+ },
1345
+ },
1346
+ ```
1347
+
1348
+ A browser file can name them in `defineFile`:
1349
+
1350
+ ```ts
1351
+ export const testkit = defineFile({ engine: "playwright", locks: ["payments-sandbox-webhook"] });
1130
1352
  ```
1131
1353
 
1132
1354
  ### Node setup beside a test
@@ -1153,9 +1375,10 @@ invocation. Files without setup still batch.
1153
1375
 
1154
1376
  Setup runs with its own JavaScript state. It can use Node APIs, prepare
1155
1377
  external resources, and start HTTP capture listeners. Tests
1156
- reach a capture by its `id` through [`handle()`](#handles). A capture's
1157
- `requestsUrl` and `resetUrl` hold an unguessable segment that Testkit keeps out
1158
- of the run's records, and its capture `path` may not start with `/__testkit__/`.
1378
+ reach a capture by its `id` through [`handle()`](#handles). A capture's `url`,
1379
+ `requestsUrl` and `resetUrl` hold unguessable segments that Testkit keeps out
1380
+ of the run's records. Its capture `path` may not start with `/__testkit__/` or
1381
+ contain `.` or `..` segments.
1159
1382
  The context contains `deadlineMs`, `signal`, `env`, `leaseDir`, `productDir`,
1160
1383
  `serviceName`, `testFile`, and `sidecars`. `serviceName` holds the name of the
1161
1384
  test's group. Setup has the same permissions as the Testkit process. It is not
@@ -1237,11 +1460,182 @@ test("lists one page of orders", async (context) => {
1237
1460
  - A failed budget fails its test when the test finishes. The failure detail has
1238
1461
  the kind `data-flow-budget` and a key made of the test's full title and the
1239
1462
  budget.
1240
- - Testkit writes `data-flow.json` and `data-flow.md` for a file whose tests
1463
+ - Testkit writes `data-flow.json` and `data-flow-report.md` for a file whose tests
1241
1464
  recorded something. Each such test is one scenario, from its final attempt. A
1242
1465
  test that records nothing adds nothing, and a file whose tests record
1243
1466
  nothing gets no data-flow artifacts.
1244
1467
 
1468
+ ### Slow the browser's network and CPU
1469
+
1470
+ `emulateNetwork(page, conditions)` and `emulateCpu(page, slowdown)` from
1471
+ `@gfdelarue/testkit/playwright` slow a page the way Chrome DevTools'
1472
+ throttling does. A `ui` test can use them to fail when a change makes a page
1473
+ unusable on a slow connection or a slow device:
1474
+
1475
+ ```ts
1476
+ import { defineFile } from "@gfdelarue/testkit";
1477
+ import { emulateCpu, emulateNetwork, expect, test } from "@gfdelarue/testkit/playwright";
1478
+
1479
+ export const testkit = defineFile({ engine: "playwright" });
1480
+
1481
+ test("the dashboard is usable on a slow connection", async ({ page }) => {
1482
+ await emulateNetwork(page, "slow-4g");
1483
+ await page.goto("/dashboard");
1484
+ await expect(page.getByRole("heading", { name: "Orders" })).toBeVisible({ timeout: 10_000 });
1485
+ });
1486
+
1487
+ test("the dashboard is usable on a slow phone", async ({ page }) => {
1488
+ await emulateCpu(page, 4);
1489
+ await page.goto("/dashboard");
1490
+ await expect(page.getByRole("heading", { name: "Orders" })).toBeVisible({ timeout: 10_000 });
1491
+ });
1492
+ ```
1493
+
1494
+ The assertion's timeout is the time limit. The helpers add no timing or
1495
+ metrics API.
1496
+
1497
+ `emulateNetwork` takes a profile name or custom values. Each profile has the
1498
+ values of the DevTools preset of the same name in the Chromium that Testkit
1499
+ runs:
1500
+
1501
+ | Profile | DevTools preset | Download | Upload | Latency |
1502
+ | ----------- | --------------- | ---------- | ---------- | -------- |
1503
+ | `"fast-4g"` | Fast 4G | 8,100 kbps | 1,350 kbps | 165 ms |
1504
+ | `"slow-4g"` | Slow 4G | 1,440 kbps | 675 kbps | 562.5 ms |
1505
+ | `"3g"` | 3G | 400 kbps | 400 kbps | 2,000 ms |
1506
+
1507
+ DevTools renamed two presets in 2024: Slow 4G was Fast 3G, and 3G was Slow 3G.
1508
+ Custom values give speeds in kilobits per second and latency in milliseconds:
1509
+
1510
+ ```ts
1511
+ await emulateNetwork(page, { latencyMs: 300, downloadKbps: 400, uploadKbps: 200 });
1512
+ ```
1513
+
1514
+ `emulateCpu` makes the page's main thread, which runs its JavaScript and
1515
+ layout, `slowdown` times slower. DevTools' fixed CPU options are 4, 6, and 20.
1516
+ Testkit doesn't calibrate the slowdown to the machine, as DevTools' "Mid-tier
1517
+ mobile" and "Low-tier mobile" options do. `emulateCpu(page, 1)` restores full
1518
+ speed.
1519
+
1520
+ - Call the helpers before `page.goto()`. Both settings last across navigations
1521
+ until the page closes.
1522
+ - Neither setting reaches a cross-site iframe. Chromium runs one in a process
1523
+ of its own, at full speed.
1524
+ - The network conditions apply to that page only. A popup it opens, or another
1525
+ page, loads at full speed. Playwright's `browserContext.setOffline()` clears
1526
+ them.
1527
+ - The CPU slowdown applies to the browser process that runs the page. A
1528
+ same-site popup that keeps its `window.opener` shares that process and slows
1529
+ down too, and closing it ends the slowdown. Call `emulateCpu` again after
1530
+ closing one.
1531
+ - Like DevTools, `emulateNetwork` also sets `navigator.connection` until the
1532
+ page navigates to another site.
1533
+ - An unknown profile name or field, a negative or non-finite value, a speed of
1534
+ 0, or a CPU slowdown outside 1 to 100 throws an error that names the value.
1535
+
1536
+ Three things to keep in mind:
1537
+
1538
+ - Set generous time limits. A CI runner has less CPU than a laptop, and a
1539
+ [campaign](#run-a-campaign) files a failing test as a bug, so a tight limit
1540
+ produces false bug reports.
1541
+ - A CPU slowdown multiplies the speed of the machine that runs the test, so 4
1542
+ on a slow CI runner is much slower than 4 on a laptop. A time limit under a
1543
+ CPU slowdown depends on the runner far more than one under network
1544
+ conditions, which are the same on every machine.
1545
+ - Chromium holds each response for at least the latency and shares the
1546
+ bandwidth among the page's requests, but it doesn't model TCP. The result
1547
+ approximates a slow connection rather than reproducing one.
1548
+
1549
+ ### Check a ui test's browser traffic
1550
+
1551
+ Testkit records the requests every `ui` test's pages send, with no change to the
1552
+ test. The record catches bugs that live in how a frontend uses its API, such as
1553
+ a checkout that sends its payment twice or a list page that fetches each row
1554
+ separately, even when no assertion checks for them.
1555
+
1556
+ For each `ui` file whose tests sent requests, Testkit writes
1557
+ `browser-network.json` and `browser-network-report.md` from each test's final
1558
+ attempt. A test's section of the report looks like this:
1559
+
1560
+ ```md
1561
+ ## checkout › pays for the cart (passed)
1562
+
1563
+ 21 requests, 452 KB received: script 312 KB, font 48 KB, document 41 KB, fetch 14 KB
1564
+
1565
+ | Service | Request | Count | Sent | Received | Status |
1566
+ | ------- | ----------------- | ----- | ------ | -------- | ------ |
1567
+ | web | GET /cart | 1 | 0 B | 41 KB | 200 |
1568
+ | api | GET /products/:id | 8 | 0 B | 11 KB | 200 |
1569
+ | api | POST /payments | 2 | 1.2 KB | 400 B | 201 |
1570
+
1571
+ Other origins: fonts.gstatic.com, 2 requests, 48 KB
1572
+
1573
+ Leads:
1574
+
1575
+ - `POST /payments` to api went out 2 times with the same body.
1576
+ ```
1577
+
1578
+ - Rows cover page loads and API calls, which are Playwright's `document`,
1579
+ `fetch`, and `xhr` requests, to the services the test's group uses. A row
1580
+ names the service rather than its URL, because each run gives services
1581
+ different ports. Scripts, styles, images, and fonts count only toward the
1582
+ totals by type. Requests to other origins appear by host name only.
1583
+ - A path's numbers, UUIDs, and hex strings of 16 or more characters become
1584
+ `:id`, so eight product fetches make one row with a count of 8.
1585
+ - The record never holds query strings, headers, or bodies. Paths pass through
1586
+ Testkit's redaction, and sealing redacts them again with the run's secrets.
1587
+ To spot a repeated write, Testkit hashes each body in memory and keeps only
1588
+ the count.
1589
+ - Sizes are body bytes as they crossed the network, so a compressed response
1590
+ counts its compressed size and one that a `page.route()` handler fulfills
1591
+ counts 0. Headers don't count.
1592
+ - Each test keeps 200 requests in detail, 100 rows, and 20 other origins. The
1593
+ record counts the rest.
1594
+ - The leads point at two patterns: a POST, PUT, PATCH, or DELETE sent more than
1595
+ once to the same URL with the same body, and a page load or API call that
1596
+ failed or got a 4xx or 5xx status in a test that passed. A lead never fails a
1597
+ test.
1598
+ - The record covers every context a test opens, including one from
1599
+ `browser.newContext()`. It doesn't cover a context that a `beforeAll` hook
1600
+ opens.
1601
+
1602
+ To assert on the traffic, call `browserRequests()` from
1603
+ `@gfdelarue/testkit/playwright`. It returns the requests the test's pages have
1604
+ sent so far, oldest first:
1605
+
1606
+ ```ts
1607
+ import { browserRequests, expect, test } from "@gfdelarue/testkit/playwright";
1608
+
1609
+ test("checkout charges the card once", async ({ page }) => {
1610
+ await page.goto("/cart");
1611
+ await page.getByRole("button", { name: "Pay" }).click();
1612
+ await expect(page.getByText("Payment received")).toBeVisible();
1613
+
1614
+ const payments = (await browserRequests()).filter(
1615
+ (request) => request.service === "api" && request.method === "POST" && request.path === "/payments",
1616
+ );
1617
+ expect(payments).toHaveLength(1);
1618
+ });
1619
+ ```
1620
+
1621
+ Each entry has `service`, the name of the service it went to or `null`,
1622
+ `method`, `url`, `path`, `resourceType`, `status`, `failure`, `requestBytes`,
1623
+ `responseBytes`, and `durationMs`. `path` is the URL's path as sent, without
1624
+ `:id`. Add up entries with plain array code, for example to check a page's
1625
+ weight:
1626
+
1627
+ ```ts
1628
+ const received = (await browserRequests()).reduce((total, request) => total + request.responseBytes, 0);
1629
+ expect(received).toBeLessThan(500_000);
1630
+ ```
1631
+
1632
+ `browserRequests()` waits up to 5 seconds for requests already in flight, so
1633
+ their entries have a status and sizes. It doesn't wait for EventSource streams.
1634
+ A request still unfinished after that has its status if the response's headers
1635
+ arrived, 0 response bytes, and a `durationMs` of `null`. A WebSocket connection
1636
+ appears with the `resourceType` `websocket` and no status, sizes, or duration.
1637
+ The record counts connections, not their messages.
1638
+
1245
1639
  ## Discovery
1246
1640
 
1247
1641
  Testkit discovers plain Vitest test files and typed suites.
@@ -1373,7 +1767,7 @@ engine.
1373
1767
  Unsupported pairs fail discovery; for example, a `*.ui.testkit.ts` file cannot
1374
1768
  declare `engine: "node-vitest"`.
1375
1769
 
1376
- See [docs/test-types.md](docs/test-types.md) for the canonical type model.
1770
+ See [`docs/test-types.md`](https://app.unpkg.com/@gfdelarue/testkit/files/docs/test-types.md) for the canonical type model.
1377
1771
 
1378
1772
  Test groups own test files by directory. Declare them under `groups` in
1379
1773
  `testkit.config.ts`. Each group lists its repo-relative `roots` and the services
@@ -1403,7 +1797,7 @@ export default defineConfig({
1403
1797
  are also available by name; see
1404
1798
  [Named endpoints and databases](#named-endpoints-and-databases).
1405
1799
  - `skip`, `requirements`, and `runtimeIsolation` are group fields. See the
1406
- [config reference](testkit-config-schema.md#groups).
1800
+ `groups` section of [`testkit-config-schema.md`](https://app.unpkg.com/@gfdelarue/testkit/files/testkit-config-schema.md).
1407
1801
 
1408
1802
  Services own no test files. A config with `services` and no `groups` key
1409
1803
  fails to load when a service's `local.cwd` or `discovery.roots` holds a typed
@@ -1472,11 +1866,16 @@ Testkit ignores an old `.testkit/history/` directory and leaves it in place.
1472
1866
  `@gfdelarue/testkit` provisions Docker-managed local Postgres automatically for
1473
1867
  services that define `database: database.postgres(...)`.
1474
1868
 
1475
- Docker is also the required Postgres client runtime for Testkit-owned database
1476
- operations. Testkit runs `psql` and `pg_dump` inside the configured Postgres
1477
- client image instead of using host-installed binaries, which keeps local runs
1478
- consistent across Linux, macOS, and native Windows. Override the client image
1479
- with `TESTKIT_POSTGRES_CLIENT_IMAGE` when needed.
1869
+ Docker is also the required Postgres client runtime. Testkit doesn't use
1870
+ host-installed `psql` or `pg_dump`, which keeps local runs consistent across
1871
+ Linux, macOS, and native Windows. Testkit's own operations on the database it
1872
+ owns, such as declaring roles and taking schema snapshots, run inside the
1873
+ database container. So the `image` that `database.postgres()` uses must include
1874
+ `psql` and `pg_dump`, with a `pg_dump` at least as new as its server. Your SQL,
1875
+ in `sql-file` steps and `database-steps` helpers, runs `psql` in a separate
1876
+ client container from `postgres:16`. A `pg_dump` of an external database runs
1877
+ in `postgres:<its major version>`. `TESTKIT_POSTGRES_CLIENT_IMAGE` overrides
1878
+ the image of those client containers.
1480
1879
 
1481
1880
  Template steps, schema snapshots, and `database.steps` helpers work the same
1482
1881
  under Docker Desktop, rootless Docker, and Docker-in-Docker. Clients for
@@ -1502,7 +1901,7 @@ one with `database(name)` from `@gfdelarue/testkit/environment`. Declared `env`
1502
1901
  `{dbPort}`, `{dbName}`, `{dbUser}`, `{dbPassword}`, and `{dbSsl}` tokens, with
1503
1902
  `:serviceName` to read the database of a service listed in `dependsOn`. Testkit
1504
1903
  resolves them from the database provisioned for the current run. They are
1505
- env-only; see [`env`](testkit-config-schema.md#env).
1904
+ env-only; see the `env` section of [`testkit-config-schema.md`](https://app.unpkg.com/@gfdelarue/testkit/files/testkit-config-schema.md).
1506
1905
 
1507
1906
  `db schema refresh` forces a source database dump into the `.testkit` source
1508
1907
  schema cache. `db schema verify` prepares local templates and verifies local