@ultimat3/cli 29.4.0 → 30.0.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/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +11 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +9 -0
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +41 -81
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +7 -1
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1313 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
package/wiki/Testing.md
ADDED
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
`bun test`. N workers, N real Postgres databases, sealed network. No mocking the database, no rollback hacks, no shared-state flakes.
|
|
4
|
+
|
|
5
|
+
Tests live next to their source as `<file>.test.ts`.
|
|
6
|
+
|
|
7
|
+
## Parallel by database clone
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
x test unit --workers 4 # one bun test --parallel=4: Bun's workers 1..4
|
|
11
|
+
worker 1 → ultimate_test_template_w1 (CREATE DATABASE … TEMPLATE ultimate_test_template)
|
|
12
|
+
worker 2 → ultimate_test_template_w2
|
|
13
|
+
...
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`--workers` is `x test`'s flag, spent as Bun's `--parallel`; `bun test` itself has no `--workers`.
|
|
17
|
+
|
|
18
|
+
| Step | Mechanism |
|
|
19
|
+
|---|---|
|
|
20
|
+
| Once per run | `pg_advisory_lock`, then the template database is created and migrated; the first worker builds it, the rest wait on the lock and reuse it |
|
|
21
|
+
| Per worker | `DROP DATABASE IF EXISTS … WITH (FORCE)`, then `CREATE DATABASE … TEMPLATE` the template — Postgres file-copies |
|
|
22
|
+
| Per test file | the worker's database with its DATA reset to the template's (`reusableDatabase`, below) |
|
|
23
|
+
| No `TEST_DATABASE_URL` / `DATABASE_URL` | PGlite in memory behind the same handle |
|
|
24
|
+
| Teardown | `drop()` on the worker's handle. There is no keep-the-database flag, `As of 2026-11` |
|
|
25
|
+
|
|
26
|
+
### Rejected approaches
|
|
27
|
+
|
|
28
|
+
| Common approach | Why rejected |
|
|
29
|
+
|---|---|
|
|
30
|
+
| Wrap each test in a transaction and roll back | breaks anything that commits: the outbox, `LISTEN/NOTIFY`, logical replication, real isolation levels, nested transactions, and every job test. You end up testing a code path production never runs |
|
|
31
|
+
| One shared test DB with serial tests | slow, and the first flake teaches everyone to re-run instead of read |
|
|
32
|
+
| One shared DB with parallel tests | shared-state flakes. The failures are order-dependent, unreproducible, and eventually the suite is ignored |
|
|
33
|
+
| Mock the database | tests pass, SQL is wrong. The main thing worth testing is the query |
|
|
34
|
+
|
|
35
|
+
Real databases, truly parallel, is the only combination that is both fast and honest.
|
|
36
|
+
|
|
37
|
+
## Database state, and what a run leaves behind
|
|
38
|
+
|
|
39
|
+
`As of 27.3.0`. The framework's DatabaseCleaner and `tmp:clear` in one place: every test starts from a known database by construction, and what a run leaves on disk or on the server is swept by the framework, never by the app.
|
|
40
|
+
|
|
41
|
+
**The migrated database.** `migratedDatabase({ root })` from `@ultimat3/cli` is a fresh embedded Postgres holding the framework schema (`x_jobs`, `x_users`, …) and every migration under `packages/db/migrations` — exactly what `x db migrate` applies. It boots from a template (the migrated data directory, dumped once per PGlite version, framework schema and migration set) cached at `<app root>/.x/test-db/pglite-<key>.tar`: a restore is ~0.5 s where initdb plus the migrations is 4-7 s. A changed migration is a new key, so a stale template is never loaded. The recipe, once per app, in the app's own test-support module:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { migratedDatabase } from '@ultimat3/cli';
|
|
45
|
+
import { setDbClient } from '@ultimat3/db';
|
|
46
|
+
import { afterAll, beforeAll, reusableDatabase } from '@ultimat3/testing';
|
|
47
|
+
|
|
48
|
+
const workerDatabase = reusableDatabase(() => migratedDatabase({ root: import.meta.dir }));
|
|
49
|
+
|
|
50
|
+
export function useMigratedDatabase(): void {
|
|
51
|
+
beforeAll(async () => setDbClient(await workerDatabase()), 60_000);
|
|
52
|
+
afterAll(() => setDbClient(undefined));
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Scope | What it gets |
|
|
57
|
+
|---|---|
|
|
58
|
+
| a test **file** | the worker's database with its DATA reset to the template's — every table truncated and re-filled, every sequence reset, triggers off meanwhile (`reusableDatabase`) |
|
|
59
|
+
| a **test** inside a file | the same database as the file's other tests: nothing is rolled back between them (a rollback would break every commit-dependent path). Give each test its own ids |
|
|
60
|
+
| DDL a test runs | not undone — a test that changes the schema opens its own `migratedDatabase` and closes it |
|
|
61
|
+
| a Postgres server (`TEST_DATABASE_URL`) | a database per worker cloned from the template (`describeApp`), or a per-run probe database (`probeDatabaseName`), dropped at teardown |
|
|
62
|
+
|
|
63
|
+
**The handle follows the installed client**, `As of 30.0`. The scaffolded `packages/db/src/client.ts` picks `testDriver()` under test (`storeMode(env) === 'memory' ? testDriver() : postgresDriver()`): memory until `setDbClient` installs one, that client while it is, decided per repository call. So `db.<table>` and a repo's raw `sql` join read and write one database in a file that runs the recipe, and a file that never runs it is the memory driver exactly: no PGlite loaded, no WASM cost. An app scaffolded before 30.0 makes that one-line edit in its own `client.ts`; until then its handle stays in memory while raw SQL reaches the installed client. Worked example: `examples/dummy/apps/web/app/posts/repo-join.test.ts`.
|
|
64
|
+
|
|
65
|
+
| The test is about | Suite |
|
|
66
|
+
|---|---|
|
|
67
|
+
| a repo's handle calls, a service, a policy | a plain `.test.ts` on memory |
|
|
68
|
+
| a repo mixing the handle with a raw join, an aggregate, a CTE | a `.test.ts` running the recipe above |
|
|
69
|
+
| behaviour only a real server has (a second connection, `LISTEN`, a lock across sessions) | `.live.test.ts` against `TEST_DATABASE_URL` |
|
|
70
|
+
| a raw join paged with `keyset()` | the recipe above: raw SQL has no memory path, so a page boundary is asserted on the real engine |
|
|
71
|
+
| text order where it matters | the recipe above, `As of 2026-11-06`. Memory sorts text by code unit (`["10","9","A","B","a"]`), Postgres by the column's collation (`["10","9","a","A","a b"]` on `en_US`): `.orderBy('name').page()` agrees on order only on the real engine |
|
|
72
|
+
|
|
73
|
+
**What a run leaves behind is swept** — before every `x test` and every test step of `x verify` (once per process), and on demand by `x clean`:
|
|
74
|
+
|
|
75
|
+
| Leftover | Before a run | `x clean` |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `.x/test-db/pglite-*.tar` templates | the newest two stay — the current migration state and one a second checkout may be on. Writing a new one evicts the same way | all |
|
|
78
|
+
| `.x/cache` — PGlite initdb snapshots, the Sass cache | snapshots: the newest two stay | the whole folder |
|
|
79
|
+
| a `.x` below the app root (written beside the code by a process started in a source folder, before 27.3.0) | removed | removed |
|
|
80
|
+
| a probe database whose process is dead, with no connection, older than 10 min (`TEST_DATABASE_URL` set) | dropped | dropped |
|
|
81
|
+
| `.x/pgdata` (the dev database), `.x/storage`, the build output | never touched | never touched |
|
|
82
|
+
|
|
83
|
+
Every `.x` path a framework module writes resolves from the APP ROOT (`appStatePath` in `@ultimat3/core`), whichever folder the process started in. A temp directory a framework test makes is removed by the file that made it; `scripts/temp-dir-cleanup.test.ts` refuses one that is not.
|
|
84
|
+
|
|
85
|
+
## Determinism
|
|
86
|
+
|
|
87
|
+
Any test that can pass twice and fail the third time is worse than no test — it trains people to ignore red.
|
|
88
|
+
|
|
89
|
+
| Control | Behavior |
|
|
90
|
+
|---|---|
|
|
91
|
+
| **Seeds** | `seed(name)` builds a named, deterministic fixture graph via entity factories. Same input → identical rows, identical UUIDs |
|
|
92
|
+
| **Frozen clock** | time starts at a fixed instant. `clock.advance('3d')` moves it, and it also drives `step.sleep` and cron in tests |
|
|
93
|
+
| **Seeded RNG** | `Math.random`, `crypto.randomUUID`, and Bun's RNG are seeded per test file from its path — reproducible, distinct across files |
|
|
94
|
+
| **Sealed network** | a `fetch`, a `new WebSocket(…)` or a `Bun.connect(…)` to anywhere not explicitly mocked or allowed **fails the test** with `X_TEST_NETWORK_SEALED`, naming the URL and the fix — the three dials share one gate, so the allow-list and `offline()` apply to all of them — `offline()` refuses a loopback dial too (`X_TEST_NETWORK_OFFLINE`). A dial that goes around those three globals (a `node:net` socket, `Bun.udpSocket`) is not sealed |
|
|
95
|
+
| **Fixed timezone + locale** | `UTC` and `en-US` unless a test declares otherwise; a tz-dependent bug fails deterministically |
|
|
96
|
+
| **Ordered concurrency** | job workers in tests run deterministically; `runJobs()` drains the queue synchronously |
|
|
97
|
+
| **Per-test reset** | before EVERY test, not every file, the preload puts the jobs event bus back (`installPerTestReset()`): an answer one test published never resumes the next test's waiting run, with or without `runJobs`. A test that never loaded `@ultimat3/jobs` pays nothing. `As of 2026-10` |
|
|
98
|
+
| **Seal key** | a test over a `.sealed()` column needs no key: the preload sets `ULTIMATE_SECRETS_KEY` to a fixed throwaway when `NODE_ENV=test` and the app has neither the variable nor `.secrets.key`. The app's own key wins. `startE2eApp` hands the same key to the app it spawns. The key is public — it ships in the package — and is installed nowhere else, `As of 2026-10` |
|
|
99
|
+
|
|
100
|
+
Sealed network is the highest-value rule: it converts "the suite is slow and occasionally fails" into "you forgot to mock Stripe, here is the line".
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
X_TEST_NETWORK_SEALED: unmocked network call
|
|
104
|
+
cause: POST https://api.stripe.com/v1/charges from app/billing/service.ts:42
|
|
105
|
+
fix: mockFetch('https://api.stripe.com/v1/charges', () => new Response('{}')) — or allowHost('api.stripe.com') if it must be real
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`mockFetch(match, answer)` holds what a retry test needs in one registration:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { expect, mockFetch, requestLog } from '@ultimat3/testing';
|
|
112
|
+
|
|
113
|
+
declare function charge(): Promise<void>; // yours: the code under test
|
|
114
|
+
|
|
115
|
+
mockFetch('POST https://api.vendor.test/charge', [
|
|
116
|
+
new Response('busy', { status: 503 }),
|
|
117
|
+
new Response('{"id":"ch_1"}'),
|
|
118
|
+
]);
|
|
119
|
+
await charge();
|
|
120
|
+
expect(requestLog()).toHaveLength(2);
|
|
121
|
+
const [first, second] = requestLog();
|
|
122
|
+
expect(second?.headers['authorization']).toBe(first?.headers['authorization']);
|
|
123
|
+
expect(second?.body).toBe(first?.body);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- `match`: an exact URL, a prefix ending in `*`, or a RegExp, every verb. A string may name one verb first, `'POST https://…'`. An address-literal URL carrying a `host` header also matches a route on that name, so a pinned dial answers the mock of its host.
|
|
127
|
+
- `answer`: a handler, or a list of `Response | Error` answered in order with the last repeated. An `Error` is thrown. An empty list is `X_INVARIANT`.
|
|
128
|
+
- `requestLog()`: `{ method, url, headers, body }[]`, in call order, refused calls and socket dials included, unredacted. Header names are lowercase. `body` is UTF-8 text or `null`; a `ReadableStream` on its way to a real server is never read and logs `null`. A stream body or a `Request`'s own is captured beside the call, never ahead of the answer: it is in the entry once it ends, `null` until then.
|
|
129
|
+
|
|
130
|
+
**A vendor's answers, recorded once** — `cassette(dir, name, options)`, `As of 2026-11`. Replay is
|
|
131
|
+
`mockFetch`: each recorded URL becomes one route answered in call order, so anything else is still
|
|
132
|
+
`X_TEST_NETWORK_SEALED`. Recording is by hand.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { cassette, expect } from '@ultimat3/testing';
|
|
136
|
+
|
|
137
|
+
declare function refund(): Promise<{ status: number }>; // yours: the code under test
|
|
138
|
+
|
|
139
|
+
await using _tape = cassette(import.meta.dir, 'refund-flow', {
|
|
140
|
+
record: { hosts: ['api.vendor.test'] },
|
|
141
|
+
});
|
|
142
|
+
expect((await refund()).status).toBe(200);
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Mode | What happens |
|
|
146
|
+
|---|---|
|
|
147
|
+
| replay (default) | reads `<dir>/__cassettes__/<name>.json`; no file is `X_CASSETTE_MISSING`; an entry never asked for fails at dispose, `X_CASSETTE_UNUSED` (`meta.unused`) |
|
|
148
|
+
| `ULTIMATE_CASSETTE=record bun test <file>` | lets `record.hosts` through the seal for the cassette's life, and writes at dispose. `CI` set, no `record.hosts`, or a dial to another host is `X_CASSETTE_RECORD_REFUSED` |
|
|
149
|
+
|
|
150
|
+
| What is written | Rule |
|
|
151
|
+
|---|---|
|
|
152
|
+
| request | never: assert what was sent with `requestLog()` |
|
|
153
|
+
| response headers | `content-type`, `retry-after`, `ratelimit-*` only |
|
|
154
|
+
| credential-named keys | masked as `[redacted]` in JSON bodies at any depth and in URL queries; a masked URL still matches the real request on replay |
|
|
155
|
+
| `redact(text)` | the app's rule, after the masking, on each URL and text body; a `[redacted]` it puts in a URL matches any one segment on replay |
|
|
156
|
+
| the secret scan | last, over the exact bytes: every credential-named env var's value and `secrets: []`, raw, JSON-escaped, percent-encoded, base64, base64url, and raw inside a base64 body. A hit writes nothing: `X_CASSETTE_SECRET` names the key and the byte offset, never the value. Values under 8 characters are skipped; a hashed or split value is not caught |
|
|
157
|
+
| bodies | text as text, anything else base64; keys sorted; temp file then rename; a body that fails to read writes nothing |
|
|
158
|
+
| streams | recorded whole, replayed as one body: a chunk-boundary test stays hand-written |
|
|
159
|
+
|
|
160
|
+
Locale and zone are declared per test when the behavior under test depends on them — see [Timezones and dates](Timezones-And-Dates) and [I18n](I18n).
|
|
161
|
+
|
|
162
|
+
## The six test types
|
|
163
|
+
|
|
164
|
+
| Type | Command | Asserts | Runs against |
|
|
165
|
+
|---|---|---|---|
|
|
166
|
+
| **unit** | `x test unit` | services, money, policy predicates, matchers, and a handler through `.as()` — what runs in process | the in-memory driver, or the migrated database recipe above (`migratedDatabase` + `reusableDatabase`) when the code reads Postgres |
|
|
167
|
+
| **contract** | `x test contract` | an action's input/output schema, its policy denials, its emitted OpenAPI + MCP tool shape, its handler on generated legal input | cloned DB |
|
|
168
|
+
| **live** | `x test live` | a live query's initial snapshot, incremental patches on write, reconnect delta, and that a policy-failing row is never delivered | cloned DB + in-process replicator + in-process NATS |
|
|
169
|
+
| **job** | `x test job` | step-level replay (a step already run is not re-run), idempotency-key dedupe, retry/backoff, concurrency and rate limits, outbox atomicity on rollback | cloned DB + frozen clock |
|
|
170
|
+
| **e2e** | `x test e2e` | a real browser (Chrome over raw CDP, no Playwright) against the app: render mode behavior, streaming holes filling, hydration timing, SW install + offline fallback, version-skew reload, two tabs, offline writes | the app spawned on a throwaway database (`ULTIMATE_STATE_DIR`) + a real browser, when Chrome is present |
|
|
171
|
+
| **eval** | `x test eval` | prompt quality vs. a baseline: exact, schema, rubric (judge), or regression tolerance | pinned models, recorded fixtures |
|
|
172
|
+
|
|
173
|
+
**Each helper takes `{ timeoutMs }`** — `unitTest(name, body, { timeoutMs: 30_000 })`, the same
|
|
174
|
+
on `contractTest`, `liveTest`, `jobTest`, `e2eTest` and `test`, and a key of `evalTest`'s options.
|
|
175
|
+
Handed to `bun:test` at this machine's pace; absent is Bun's 5 s default, paced the same way. Never `test(testName('unit', …), body, 30_000)`
|
|
176
|
+
to buy a deadline.
|
|
177
|
+
|
|
178
|
+
**`unitTest`, `contractTest`, `liveTest` and `jobTest` take [the fixture bag](#the-fixture-bag)**,
|
|
179
|
+
exactly as `test` does: `unitTest('…', async ({ runJobs, clock }) => …)`. A body that destructures
|
|
180
|
+
nothing builds nothing. Never `describe(testName('unit', …))` around a bare `test` to reach a
|
|
181
|
+
fixture. `As of 2026-10`.
|
|
182
|
+
|
|
183
|
+
**The e2e step drives a real browser, `As of 2026-09-22`** (21.0.0). When Chrome is on the
|
|
184
|
+
machine, `x verify`'s `e2e` step spawns the app with `startE2eApp` (reset, seed, boot, on its own
|
|
185
|
+
throwaway `ULTIMATE_STATE_DIR`) and runs the suite with a real `page`
|
|
186
|
+
(`packages/cli/src/verify-e2e.ts`). **Before this, the reference app's e2e suite ran with no
|
|
187
|
+
browser at all**, and its browser-backed cases skipped under a green gate; this page described
|
|
188
|
+
them as running. The session API (`openE2eBrowser()`, `cdpE2eSession`) can open a second tab in the
|
|
189
|
+
same profile (`newTab`) and add an init script. It can take the page offline, workers included,
|
|
190
|
+
and set cookies. It lists the page's sockets and requests, reads IndexedDB database names, and
|
|
191
|
+
waits for an expression (`waitFor`). That is what the two-tab and offline cases rest on. The spawned
|
|
192
|
+
app runs with `APP_URL` set to its own origin, `NODE_ENV` stripped and `ULTIMATE_ENV=development`:
|
|
193
|
+
a `NODE_ENV=test` inherited from `bun test` made it resolve as `test`, and a missing `APP_URL`
|
|
194
|
+
answered 500 with `X_ENV_MISSING`. `DATABASE_URL` and `S3_ENDPOINT` are stripped too (25.0.0,
|
|
195
|
+
#674): the state directory owns the database and the disk, and an exported integration-test
|
|
196
|
+
Postgres made `x db reset` refuse an external database and the step go red. A run that wants an
|
|
197
|
+
external one passes it in `startE2eApp({ env })`. Readiness is polled over `node:http`, because the e2e preload
|
|
198
|
+
seals `fetch` against egress. Each e2e test gets 60 s (`E2E_TEST_TIMEOUT_MS`). A spawned app
|
|
199
|
+
that will not come up is `X_E2E_APP_FAILED`.
|
|
200
|
+
|
|
201
|
+
| Fixture or call | What it is |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `e2eApp()` | the spawned app: `{ base, stateDir, stop(), restart(env?) }` |
|
|
204
|
+
| `deploy.newBuild()` | restarts the app with a new `BUILD_ID`, so a version-skew test sees a real deploy. Registered only when the runner can restart the app (`installE2eDriver({ newBuild })`); refused by name otherwise. `x dev` honours a set `BUILD_ID` for this |
|
|
205
|
+
| the liveness probe | between tests the preload asks the browser to answer (`answersWithin`) and relaunches one that stopped, so one crashed tab fails one test, not the rest of the suite |
|
|
206
|
+
|
|
207
|
+
Each type is a first-class runner with its own fixture shape — not a naming convention on top of one runner. Every command supports `--json`.
|
|
208
|
+
|
|
209
|
+
### Evals
|
|
210
|
+
|
|
211
|
+
An eval declares its cases beside the prompt (`<name>.evals.ts`) and gates on the **drop** from a
|
|
212
|
+
committed baseline, never on an absolute score — models drift, prompts should not.
|
|
213
|
+
|
|
214
|
+
| Rule | Code |
|
|
215
|
+
|---|---|
|
|
216
|
+
| a prompt no `defineEval` names | `X_EVAL_MISSING` |
|
|
217
|
+
| the run mean or a case fell past `tolerance` | `X_EVAL_THRESHOLD` |
|
|
218
|
+
| the baseline was never recorded | `X_EVAL_BASELINE_MISSING` |
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
export const summarizeEval = defineEval({
|
|
222
|
+
name: 'summarize',
|
|
223
|
+
prompt: summarizePrompt,
|
|
224
|
+
cases: [{ name: 'refund', vars: { ticket: 'I want my money back' }, expected: 'billing' }],
|
|
225
|
+
scorers: [exact, jsonSchemaValid(['category', 'summary'])],
|
|
226
|
+
baseline: import.meta.resolve('./summarize.baseline.json'),
|
|
227
|
+
tolerance: 0.05,
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`ULTIMATE_EVAL_RECORD=1 x test eval` re-records every baseline, so accepting a new number is a
|
|
232
|
+
reviewable diff instead of an edited threshold. It is refused inside the gate: `x verify` with that
|
|
233
|
+
variable set is `X_EVAL_RECORDING` and runs no eval suite, because recording passes by definition
|
|
234
|
+
and would overwrite the committed baselines during the run.
|
|
235
|
+
|
|
236
|
+
**An agent's prompt takes the `agent` arm** — one case is one whole `agent.run()`, its tools and its
|
|
237
|
+
turns, under the gateway handed to `run` / `assert`. The prompt arm sends one request with no tools,
|
|
238
|
+
which measures something an agent never runs. `As of 2026-11`:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { calledTool, contains, defineEval, type EvaluatedAgent } from '@ultimat3/ai';
|
|
242
|
+
import type { Actor } from '@ultimat3/core';
|
|
243
|
+
|
|
244
|
+
declare const supportAgent: EvaluatedAgent<{ question: string }>; // yours: an agent() action
|
|
245
|
+
declare const reviewer: Actor;
|
|
246
|
+
|
|
247
|
+
export const supportEval = defineEval({
|
|
248
|
+
name: 'support.answer',
|
|
249
|
+
agent: supportAgent, // an agent() action, registered
|
|
250
|
+
cases: [{ name: 'where-is-o-1', input: { question: 'Where is o-1?' }, expected: 'shipped' }],
|
|
251
|
+
tools: { lookupOrder: () => ({ status: 'shipped' }) }, // a double per tool the model may call
|
|
252
|
+
scorers: [calledTool('lookupOrder'), contains],
|
|
253
|
+
actor: reviewer, // optional: default is the `eval` service actor
|
|
254
|
+
baseline: import.meta.resolve('./support.baseline.json'),
|
|
255
|
+
tolerance: 0.05,
|
|
256
|
+
});
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
| Agent arm | Rule |
|
|
260
|
+
|---|---|
|
|
261
|
+
| a real tool | never runs: a call to a tool with no double is `X_EVAL_TOOL_UNDOUBLED`, thrown out of `run` / `assert` |
|
|
262
|
+
| a failed run (`X_AGENT_MAX_TURNS`, an invalid answer, a policy denial) | scores 0 on that case, its code in `cases[].failed`; the suite goes on |
|
|
263
|
+
| scorers | read `output` (the answer as JSON) and `run: { turns, toolCalls, usage }`; `calledTool(name)` is 1 or 0 |
|
|
264
|
+
| the report | `result.agent`, and per case `run: { turns, toolCalls }` |
|
|
265
|
+
| baseline | the same file, keyed by the agent's prompt hash; a model change is a new prompt version |
|
|
266
|
+
| coverage | an agent eval covers the agent's prompt; an uncovered one's `X_EVAL_MISSING` fix names the agent arm |
|
|
267
|
+
|
|
268
|
+
Recorded runs come from `scriptedProvider({ turns })`; harvesting production failures into `cases`
|
|
269
|
+
is the app's script — `transcriptOf(error)` hands over what happened.
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
// contract test — generated as a scaffold with the action
|
|
273
|
+
test('publishPost denies a non-owner', async ({ seed, actorFor }) => {
|
|
274
|
+
const { post, stranger } = await seed('two-orgs');
|
|
275
|
+
await expect(publishPost.as(actorFor(stranger), { postId: post.id }))
|
|
276
|
+
.rejects.toBeUltimateError('X_FORBIDDEN');
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
// job test — the step guarantee, not the happy path
|
|
282
|
+
test('onboardOrg retries only the failed step', async ({ seed, clock, mail }) => {
|
|
283
|
+
const { org } = await seed('fresh-org');
|
|
284
|
+
mail.failOnce(nudgeEmail);
|
|
285
|
+
await runJobs(onboardOrg, { orgId: org.id });
|
|
286
|
+
clock.advance('3d');
|
|
287
|
+
const trace = await runJobs.drain();
|
|
288
|
+
expect(trace.steps.provision.executions).toBe(1); // replayed from storage
|
|
289
|
+
expect(trace.steps['nudge'].executions).toBe(2); // only this one retried
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
The `job` example is the one that matters: it asserts the durability guarantee, not that mail was sent. See [Jobs and workflows](Jobs-And-Workflows) and [Policies and authz](Policies-And-Authz).
|
|
294
|
+
|
|
295
|
+
## The accessibility audit
|
|
296
|
+
|
|
297
|
+
**Every `e2eTest` audits the browser's accessibility tree, `As of 2026-11`** (30.0.0 entry 88). After each settled `goto`, `reload` and `gotoStreamed`, and once more after the body (so a dialog the body opened counts), the driver reads the tree the browser computed for a screen reader and fails the test once, at the end, with `X_E2E_A11Y_VIOLATION` listing every element. There is no switch.
|
|
298
|
+
|
|
299
|
+
| Rule | Fails |
|
|
300
|
+
|---|---|
|
|
301
|
+
| `name` | a node of a `NAMED_ROLES` role with a blank accessible name: button, link, textbox, searchbox, combobox, checkbox, radio, switch, slider, spinbutton, tab, menuitem, image, heading, iframe, `<summary>` |
|
|
302
|
+
| `title` | the document with no `<title>` |
|
|
303
|
+
| `lang` | `<html>` with no `lang` |
|
|
304
|
+
| `aria-hidden-focus` | a Tab-reachable element inside `aria-hidden="true"` |
|
|
305
|
+
|
|
306
|
+
Name what it lists: visible text, `aria-label`, `<label for>`, `alt` (`alt=""` for a decorative image, which the browser then leaves out of the tree), a `<title>`, `<html lang>`. A deliberate exception is per test and per element, with a reason:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
import { e2eTest, expect } from '@ultimat3/testing';
|
|
310
|
+
|
|
311
|
+
e2eTest(
|
|
312
|
+
'the gallery paints',
|
|
313
|
+
async ({ page }) => {
|
|
314
|
+
await page.goto('/gallery');
|
|
315
|
+
await expect(page.getByRole('heading', { name: 'Gallery' })).toBeVisible();
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
a11y: {
|
|
319
|
+
allow: [
|
|
320
|
+
{ role: 'image', selector: '#vendor-map img', because: 'the vendor embed sets no alt' },
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
| Fact | |
|
|
328
|
+
|---|---|
|
|
329
|
+
| matching | `role` equals the finding's role and `selector` (CSS) matches the element, tested in the page |
|
|
330
|
+
| a blank `because` | the test is refused before its body runs (`X_E2E_A11Y_VIOLATION`) |
|
|
331
|
+
| a body that throws | fails on its own error; the audit verdict is not added |
|
|
332
|
+
| once per suite | a finding one test already failed on (rule, role, markup, URL) is not reported by a later test, so a shared header fails one test |
|
|
333
|
+
| not markup | a non-`http(s)` URL (`about:blank`) and a document that is not HTML (JSON, an image) are not audited |
|
|
334
|
+
| the page port | `E2eBrowserPage.accessibilityTree?(request)`. `cdpE2eTab` implements it with one `Accessibility.getFullAXTree` per read; a page passed to `installE2eDriver({ page })` without it fails every `e2eTest` with `X_TEST_FIXTURE_UNAVAILABLE`, never a silent pass |
|
|
335
|
+
|
|
336
|
+
**And every raw tab, `As of 2026-11-05`** (entry 88). A tab a suite drives itself (`browser.session.newTab()` off `openE2eBrowser()`, or the browser's first `page`) is read after each settled `goto` and `reload`, and once more at the test's end, and the findings fail that test from the preload's one `afterEach` — no suite opts in. A raw-tab test has no options slot, so its exceptions are a call, first in the body, before the navigation that lands on them:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { test } from 'bun:test';
|
|
340
|
+
import { allowA11y, openE2eBrowser } from '@ultimat3/testing';
|
|
341
|
+
|
|
342
|
+
test('the vendor map renders', async () => {
|
|
343
|
+
allowA11y([{ role: 'image', selector: '#vendor-map img', because: 'the vendor embed sets no alt' }]);
|
|
344
|
+
const browser = await openE2eBrowser();
|
|
345
|
+
try {
|
|
346
|
+
const tab = await browser.session.newTab();
|
|
347
|
+
await tab.goto('http://localhost:3000/map');
|
|
348
|
+
} finally {
|
|
349
|
+
await browser.close();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
An entry covers the reads after the call and ends with the test; a blank `because` is refused, as in `e2eTest`. Each `goto` now spends five CDP deadlines, not three (`E2E_GOTO_MS`), for the audit's two reads. Source: `packages/testing/src/e2e-a11y.ts` (rules), `e2e-a11y-run.ts` (one test's run), `e2e-a11y-tabs.ts` (raw tabs), `ax-node.ts` (the tree reader).
|
|
355
|
+
|
|
356
|
+
## Matchers
|
|
357
|
+
|
|
358
|
+
Installed by the preload on `expect`.
|
|
359
|
+
|
|
360
|
+
| Matcher | Asserts |
|
|
361
|
+
|---|---|
|
|
362
|
+
| `toBeUltimateError(code?)` | an `X_` code with a cause and a fix; the code when given |
|
|
363
|
+
| `toDenyPolicy(context)` | the policy refuses that context |
|
|
364
|
+
| `toEmitSteps(names)` | a job runs exactly those steps, in order |
|
|
365
|
+
| `toMatchOpenApi(committed)` | no operation removed, no parameter newly required |
|
|
366
|
+
| `toBeWithinBudget(limit)` | a measured number is at or under the limit |
|
|
367
|
+
| `toRejectInput(input)` / `toAcceptInput(input)` | a schema's verdict on one input |
|
|
368
|
+
| `toEqualRow(expected)` | equality over every OWN property — the one that sees a `.sealed()` column |
|
|
369
|
+
| `toBeVisible(options?)` | waits for a locator to show; `.not` waits for it to go |
|
|
370
|
+
|
|
371
|
+
`toEqualRow` exists because `toEqual` walks enumerable properties and a repository row does not
|
|
372
|
+
enumerate a sealed column ([Sealed columns](Entities-And-Migrations#sealed-columns)):
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
const row = await db.connections.insert({ label: 'primary', password: 'hunter2' });
|
|
376
|
+
|
|
377
|
+
expect(row).toEqual({ id: row.id, label: 'primary', password: 'hunter2' }); // fails, empty diff
|
|
378
|
+
expect(row).toEqualRow({ id: row.id, label: 'primary', password: 'hunter2' }); // passes
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
A failure names each property that differs. A server-only one is named and never printed — not its
|
|
382
|
+
value, not its length.
|
|
383
|
+
|
|
384
|
+
## Rendering a page in a unit test
|
|
385
|
+
|
|
386
|
+
No server, no browser, no cast. `renderRoute` takes the route module whole and resolves it the way
|
|
387
|
+
a request does; `renderView` renders one component with its own props.
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
import { expect, renderRoute, renderView, unitTest } from '@ultimat3/testing';
|
|
391
|
+
import * as page from './page';
|
|
392
|
+
|
|
393
|
+
unitTest('the dashboard counts the posts of the org that is looking', async () => {
|
|
394
|
+
const view = await renderRoute(page, { url: 'https://example.test/dashboard', actor: viewer });
|
|
395
|
+
expect(view.data.rows).toHaveLength(2);
|
|
396
|
+
expect(view.meta.title).toBe(t('app.dashboard.title'));
|
|
397
|
+
expect(view.text).toContain(t('app.dashboard.tableTitle'));
|
|
398
|
+
expect(view.islands.map((island) => island.moduleId)).toEqual(['shared-theme-toggle']);
|
|
399
|
+
});
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
| Answer | Is |
|
|
403
|
+
|---|---|
|
|
404
|
+
| `view.html` | the markup, as the document carries it |
|
|
405
|
+
| `view.text` | what a reader sees — tags and script/style bodies removed, entities decoded. Compare it with `t('<key>')` |
|
|
406
|
+
| `view.data` · `view.meta` | what `load` resolved, and what `meta` answered for that same object |
|
|
407
|
+
| `view.islands` | the islands the render emitted. Empty on a page that ships no JavaScript |
|
|
408
|
+
|
|
409
|
+
`actor` runs `load` and the page inside a request context holding that actor. `renderView` refuses
|
|
410
|
+
a component that renders an `island()` — its hydration timing is a route's. `As of 2026-10`.
|
|
411
|
+
|
|
412
|
+
## Asserting on what an element carries
|
|
413
|
+
|
|
414
|
+
A string cannot say whether a node carries `aria-live`, or what its `onKeyDown` does. Walk the tree
|
|
415
|
+
instead, imported from the package that declares it, `@ultimat3/core/render-nodes`:
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
import { fire, one, renderNodes, withAttr } from '@ultimat3/core/render-nodes';
|
|
419
|
+
import { Menu, type MenuItem } from '@ultimat3/ui';
|
|
420
|
+
|
|
421
|
+
declare const items: readonly MenuItem[];
|
|
422
|
+
const menu = one(withAttr(renderNodes(Menu, { items }), 'role', 'menu'), 'the menu');
|
|
423
|
+
fire(menu, 'onKeyDown', { key: 'ArrowDown', preventDefault: () => undefined });
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
| Question | Use |
|
|
427
|
+
|---|---|
|
|
428
|
+
| what a reader sees: markup, text, islands, `meta` | `renderRoute` / `renderView` (`@ultimat3/testing`) |
|
|
429
|
+
| what an element holds: handlers, refs, props no string can carry | `renderNodes` / `nodesOf` (`@ultimat3/core/render-nodes`) |
|
|
430
|
+
|
|
431
|
+
`nodesOf(value, { components })`: `'expand'` (default) calls components and keeps host nodes only;
|
|
432
|
+
`'keep'` keeps each component node beside its output, so `byComponent(nodes, 'DateTime')` reads
|
|
433
|
+
the props a view handed it; `'stop'` never calls one. An island's server shell is walked; a hole is
|
|
434
|
+
its first flush, `fallback` and `error`, and its deferred `children` are never called. `one()`, `attachRef()` and `fire()` refuse with `X_INVARIANT`, naming what they sought.
|
|
435
|
+
The worked example: `examples/dummy/apps/web/app/settings/page.test.ts`. `As of 2026-11`.
|
|
436
|
+
|
|
437
|
+
## Running a mutator's local half
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
const store = memoryLocalTx({ posts: { [id]: { id, title: 'before', pending: false } } });
|
|
441
|
+
renamePost.local(store.tx, { id, orgId, title: 'after' });
|
|
442
|
+
expect(store.rows('posts')).toEqual({ [id]: { id, title: 'after', pending: true } });
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
`memoryLocalTx(seed?)` is the client store under test: keyed rows, `update` a no-op for a key it
|
|
446
|
+
does not hold, `undefined` leaving a column alone, rows frozen. Apply `local` twice and compare —
|
|
447
|
+
a rebase replays it. `As of 2026-10`.
|
|
448
|
+
|
|
449
|
+
## Seeing what you built
|
|
450
|
+
|
|
451
|
+
An assertion says a component behaved. It does not say what it looked like while it did. `x shot`
|
|
452
|
+
is the other half: a real browser, a PNG, and a `verdict.json` that says whether the picture is of
|
|
453
|
+
what you think it is.
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
x shot /dashboard --json # one route
|
|
457
|
+
x shot --island post-form --json # one island, every state it declares, both themes
|
|
458
|
+
x shot --island post-form --state refused --json
|
|
459
|
+
x shot --all-islands --json # every island in the app, plus an index
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
**`x shot` is deliberately not a step of `x verify`.** It needs a real browser, and a gate that
|
|
463
|
+
goes red because a machine has no Chrome fails for reasons unrelated to the change. `x shot`
|
|
464
|
+
launches Chrome itself over raw CDP — no browser library in the app. It takes the first of
|
|
465
|
+
`--browser`, `PUPPETEER_EXECUTABLE_PATH`, `CHROME_PATH`, then a probe of the usual install paths; or
|
|
466
|
+
it attaches to a running browser with `--cdp-url` (or `SCRAPE_CDP_URL`). No browser at all is
|
|
467
|
+
`X_SHOT_BROWSER_MISSING`, raised before the dev server boots: `export CHROME_PATH=<binary>`.
|
|
468
|
+
|
|
469
|
+
`As of 2026-09-23`.
|
|
470
|
+
|
|
471
|
+
### States are declared, not clicked
|
|
472
|
+
|
|
473
|
+
A state a running app will not produce on request — a save the server refused, a read that came
|
|
474
|
+
back empty, a label three times as long in the next locale — is unreachable to a reviewer and to a
|
|
475
|
+
model. Declare it instead, in a `<name>.island.states.ts` beside the island:
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
import { defineIslandStates } from '@ultimat3/testing';
|
|
479
|
+
|
|
480
|
+
export const postFormStates = defineIslandStates({
|
|
481
|
+
island: 'apps/web/app/post/post-form.island.tsx',
|
|
482
|
+
states: [
|
|
483
|
+
{ id: 'idle', title: 'the first paint', props: {} },
|
|
484
|
+
{
|
|
485
|
+
id: 'refused',
|
|
486
|
+
title: 'the server refused the save',
|
|
487
|
+
note: 'unreachable by clicking — the action only fails for an actor without the capability',
|
|
488
|
+
props: { error: 'You cannot publish in this org.' },
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
id: 'slow',
|
|
492
|
+
title: 'the save is in flight',
|
|
493
|
+
note: 'a request that never settles; a real one resolves faster than a screenshot',
|
|
494
|
+
props: {},
|
|
495
|
+
routes: [{ match: 'POST /api/post', respond: { kind: 'pending' } }],
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
});
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
| Field | Is |
|
|
502
|
+
|---|---|
|
|
503
|
+
| `island` | app-root-relative path of the `.island.tsx` these belong to |
|
|
504
|
+
| `states[].id` | a slug. It becomes the filename stem, so it is guessable or it is nothing |
|
|
505
|
+
| `states[].title` | one line: what this state **is** |
|
|
506
|
+
| `states[].note` | why it deserves a picture — usually "you cannot reach this by clicking, because …" |
|
|
507
|
+
| `states[].props` | JSON, riding the same `data-x-props` seam hydration already uses |
|
|
508
|
+
| `states[].routes` | stubs for whatever the component fetches: `{ kind: 'json', status?, body }`, `{ kind: 'records', status?, data, records, removed? }`, `{ kind: 'pending' }` or `{ kind: 'offline' }`, matched as a `"<METHOD> <pathname>"` prefix. `records` is what a query declaring an entity's `rows:` answers — the envelope behind `x-ultimate-records: 1`, built by core's `encodeRecordEnvelope` — so the page store adopts `records` (type → key → row) exactly as from the server; a `json` stub of the same body is read as bare rows and adopts nothing (25.0.0, #683). A malformed envelope is refused at declaration (`X_TEST_ISLAND_STATE_STUB_INVALID`) |
|
|
509
|
+
| `states[].viewport` · `themes` | per state. Both themes unless the state is only meaningful in one |
|
|
510
|
+
| `viewport` · `target` · `timeZone` · `now` | manifest-wide. `1280x800`, the island's host element, `UTC`, and the suite's frozen instant |
|
|
511
|
+
|
|
512
|
+
Rules the file is held to:
|
|
513
|
+
|
|
514
|
+
| Rule | Refused by |
|
|
515
|
+
|---|---|
|
|
516
|
+
| pure data — no sibling import, no framework import, no Solid | `X_TEST_ISLAND_STATES_NOT_PURE`, read off the text at load: a module importing Solid evaluates fine under Bun, so no amount of loading it would notice |
|
|
517
|
+
| the manifest declares at least one state | `X_TEST_ISLAND_STATES_EMPTY` |
|
|
518
|
+
| a state id is a slug, unique within the manifest | `X_TEST_ISLAND_STATE_ID_INVALID` · `X_TEST_ISLAND_STATE_DUPLICATE` |
|
|
519
|
+
| `props` are JSON | `X_TEST_ISLAND_STATE_JSON_INVALID` |
|
|
520
|
+
| a route stub matches `"<METHOD> <pathname>"` | `X_TEST_ISLAND_STATE_STUB_INVALID` |
|
|
521
|
+
| `timeZone` is an IANA name and `now` carries an explicit offset | `X_TEST_ISLAND_STATE_CLOCK_INVALID` — one code for both |
|
|
522
|
+
| every island has one | `guards/island-without-states.ts`, in `x verify`'s `boundaries` step |
|
|
523
|
+
|
|
524
|
+
`x g island <name>` and `x g resource <name>` write the states file with the island — two states,
|
|
525
|
+
one of them a translation three times as long. You are editing a generated file, never creating one.
|
|
526
|
+
|
|
527
|
+
### The index is the artifact
|
|
528
|
+
|
|
529
|
+
A run writes `.x/shot/island/index.md` — one Markdown file an agent opens instead of guessing at
|
|
530
|
+
forty PNGs. Written for a single-island run too: the file that says what a picture **is** cannot be
|
|
531
|
+
a property of how many islands you asked for.
|
|
532
|
+
|
|
533
|
+
| Per | The index states |
|
|
534
|
+
|---|---|
|
|
535
|
+
| run | islands, states and pictures counted; the three commands that reproduce it; what the capture cannot see |
|
|
536
|
+
| island | its name, its source path, and `ok` or `FAILED` |
|
|
537
|
+
| state | the id, the title, the `note`, one picture path per theme, and a verdict |
|
|
538
|
+
| a failure | its reasons, listed rather than collapsed — `no picture (dark)`, `never mounted (light)`, `2 requests no stub answers`, `1 uncaught exception`, `3 console errors` |
|
|
539
|
+
| a note | recorded and **not** gating — console warnings, and content overflowing the crop target |
|
|
540
|
+
|
|
541
|
+
Pictures land at `.x/shot/island/<name>/<state>-<theme>.png`. Flat and mechanical, because the
|
|
542
|
+
reader guesses the path.
|
|
543
|
+
|
|
544
|
+
The blind spots are in the artifact rather than in this page: the picture is the crop target and a
|
|
545
|
+
thin margin, so a component's own fault sitting further out is outside the frame, and a
|
|
546
|
+
`toLocaleString()` on a `Date` resolves its zone inside the engine where only an explicit
|
|
547
|
+
`timeZone` is pinned. The index prints both under "What this capture cannot see".
|
|
548
|
+
|
|
549
|
+
What the rendered result is then held to: [Interface rules](Interface-Rules).
|
|
550
|
+
|
|
551
|
+
## The fixture bag
|
|
552
|
+
|
|
553
|
+
`test` and the typed helpers pass a bag as the first argument and build only what the body destructures — a test that never names `runJobs` never starts a queue. The framework owns the whole bag; an app registers only what the framework cannot know.
|
|
554
|
+
|
|
555
|
+
| Fixture | Is | Built by |
|
|
556
|
+
|---|---|---|
|
|
557
|
+
| `clock` | `now()` · `advance('3d')` · `set(instant)` | the preload |
|
|
558
|
+
| `mail` | `outbox()` · `lastTo(address)` · `failOnce(mail)` | the preload |
|
|
559
|
+
| `network` | `offline()` · `drop()` · `online()` · `state()` | the preload |
|
|
560
|
+
| `push` | `subscribe(actorId, { locale })` · `sent()` — each delivery decrypted, as the device shows it · `answerOnce(status)` | the preload |
|
|
561
|
+
| `runJobs` | enqueue+drain, then `enqueue()` `drain()` `due()` `inFlight()` `depth()` → [A job under `runJobs`](#a-job-under-runjobs) | the preload |
|
|
562
|
+
| `statements` | `all()` · `count(fingerprint?)` · `shapes()` — and an N+1 fails the test | the preload |
|
|
563
|
+
| `page` | `goto` · `gotoStreamed` · `getByRole` · `evaluate` · `waitForServiceWorker` | a browser driver |
|
|
564
|
+
| `budget` | `jsBytes(route)` off the built output | a browser driver |
|
|
565
|
+
| `signIn` | put the browser session in a member's shoes | a browser driver |
|
|
566
|
+
| `deploy` | `newBuild()` — same app, new build id, page still open | a browser driver |
|
|
567
|
+
| `subscribe` | one subscriber's `rows()` `patches()` `settled()` `lsn()` | a replicator |
|
|
568
|
+
| `seed`, and anything else | the app's own graph | the app's `scripts/test-setup.ts` |
|
|
569
|
+
|
|
570
|
+
**`waitForServiceWorker({ timeoutMs? })` hands the first page over** (25.0.0, #678). The framework's register script runs on `load`, so the first page of a run is committed before its worker activates and `clients.claim()` can miss it: `ready` resolves and no `controllerchange` ever fires. The wait answers `ready`, gives the claim a second (`CLAIM_GRACE_MS`), and reloads an uncontrolled page ONCE — that navigation is answered by the worker — within one budget (`serviceWorkerTimeoutMs`, 10 s, or this call's `timeoutMs`). A page with no worker is refused without a reload, and a reload still uncontrolled is refused saying so (`X_E2E_SERVICE_WORKER_ABSENT`). Proven against a real worker that never claims: `packages/testing/e2e/service-worker-handover.e2e.test.ts`.
|
|
571
|
+
|
|
572
|
+
The last five are **declared, not built**. The framework does not bundle a browser, so the name resolves and asking for it without a driver fails as `X_TEST_FIXTURE_UNAVAILABLE` naming what is missing — different from `X_TEST_FIXTURE_UNKNOWN`, whose fix ("register it") would have the app inventing its own idea of what a page is. A driver installs through the same registry:
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
// scripts/test-setup.ts
|
|
576
|
+
defineFixtures({ page: () => openBrowserPage(), seed: () => loadSeed });
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
`defineFixtures` merges and the last registration wins, so a driver replaces the declaration it was waiting on. There is no second seam.
|
|
580
|
+
|
|
581
|
+
`network.offline()` fails every request ahead of the mocks, so the app's own offline path runs instead of a branch written for the test; `drop()` is the same for a request but tells a subscriber its connection was cut rather than closed, which is what separates a resume from a resubscribe.
|
|
582
|
+
|
|
583
|
+
## A job under `runJobs`
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
unitTest('a row deleted before the run is skipped, not failed', async ({ runJobs }) => {
|
|
587
|
+
const trace = await runJobs(reindexPost, { id, orgId });
|
|
588
|
+
expect(trace.executions.map((run) => run.outcome)).toEqual(['completed']);
|
|
589
|
+
expect(trace.executions[0]?.result).toEqual({ skipped: true });
|
|
590
|
+
expect(Object.keys(trace.steps)).toEqual(['load']);
|
|
591
|
+
});
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
| On the trace or the call | Is |
|
|
595
|
+
|---|---|
|
|
596
|
+
| `trace.executions[n].result` | what the body returned. Absent on a run that failed, suspended or was interrupted |
|
|
597
|
+
| `trace.steps[name].executions` | times the step body ran — a replay from storage does not count. Cumulative for the test |
|
|
598
|
+
| `runJobs(job, input, { actor })` · `drain({ actor })` | the identity the **worker** runs as (`WorkerOptions.context()` in a real one). Its org is still the job's declared `tenant`; `tenant: 'none'` strips it. Absent is the anonymous actor |
|
|
599
|
+
| `runJobs(job, input, { tenantId })` · `enqueue(…, { tenantId })` | the **enqueuer's** tenant on the row: the limiter's bucket and the dedupe namespace, as `handle.as(actor, input)` files it. One key in two tenants is two jobs |
|
|
600
|
+
| the event bus | fresh per fixture: one test's `publishEvent` never resumes the next test's `step.waitForEvent` |
|
|
601
|
+
|
|
602
|
+
Enforced through it, keyed or not: `concurrency` and `whenBusy` — each pass is a real worker's `tick()`, so a run over its slot waits for the next pass, or is refused under `whenBusy: 'fail'`. The lease is renewed on the test clock — every millisecond of it — so a body in flight learns of a cancel at the next `clock.advance()`: cancel, `clock.advance(1)`, await the drain. A `clock.advance()` past the visibility timeout while it runs is `X_JOB_LEASE_LOST`, as in production. `As of 2026-10`.
|
|
603
|
+
|
|
604
|
+
## A job killed at every boundary, and raced from a seed
|
|
605
|
+
|
|
606
|
+
`crashProof(handle, input)` and `simulateJobs({ … })`, both from `@ultimat3/testing`, judge a job by its **effects** — row writes, mail, calls out, enqueues — never its return value. Reads are not effects. `As of 2026-11`.
|
|
607
|
+
|
|
608
|
+
| Call | Runs | Answers |
|
|
609
|
+
|---|---|---|
|
|
610
|
+
| `crashProof(job, input)` | one clean run, then one per boundary it crossed (each step write landing, each settle), the worker killed there and the next one reclaiming; then each boundary again with the write's answer lost | `{ boundaries, divergences, reruns }`. A divergence carries `error`, an `X_JOB_CRASH_DIVERGED` naming the effect and the step it follows — `throw report.divergences[0].error`. A rerun is at-least-once, reported, never red |
|
|
611
|
+
| `simulateJobs({ jobs: [{ handle, input }], workers, faults, invariant })` | `seeds` (20) seeds from `seed` (1), N workers, every driver call interleaved and faulted by a seeded picker: `kill`, `answer-lost`, `lease-lapse`, `duplicate-claim`, `clock-jump` (`ALL_FAULTS`) | throws `X_JOB_SIM_INVARIANT` with the seed and its faults when `invariant(state)` throws or answers `false`; `X_JOB_SIM_NONDETERMINISTIC` when one seed traces two ways. `x test job --seed <n>` replays one |
|
|
612
|
+
|
|
613
|
+
A `compensates: true` job is also forced to fail for good and killed while it unwinds: each `compensate:*` once, last in first out. A kill inside `step.run(…, { once: true })` ends `X_STEP_OUTCOME_UNKNOWN` and is green. Postgres is not simulated. The reference: `examples/dummy/apps/web/app/posts/jobs.job.test.ts`.
|
|
614
|
+
|
|
615
|
+
## A model call under test
|
|
616
|
+
|
|
617
|
+
`echoProvider()` answers text; it cannot call a tool. A test that drives an `agent()` through its tools scripts the model with `scriptedProvider({ turns })`: each provider call answers the next turn, and `.seen` keeps every request in order.
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
import { configureAi, meteredRun, providerGateway, scriptedProvider } from '@ultimat3/ai';
|
|
621
|
+
|
|
622
|
+
declare const draft: { id: string; orgId: string };
|
|
623
|
+
declare function reviewDraft(input: { postId: string; orgId: string }): Promise<unknown>;
|
|
624
|
+
|
|
625
|
+
const model = scriptedProvider({
|
|
626
|
+
turns: [
|
|
627
|
+
{ call: 'summarize', input: { postId: draft.id, orgId: draft.orgId } },
|
|
628
|
+
{ answer: { summary: 'Minor units and a currency.', tags: ['money'] } }, // the tool's own llm()
|
|
629
|
+
{ answer: { verdict: 'revise', notes: 'Say which rounding rule the body defends.' } },
|
|
630
|
+
],
|
|
631
|
+
});
|
|
632
|
+
configureAi({ gateway: providerGateway({ providers: [model] }) });
|
|
633
|
+
|
|
634
|
+
// The agent's turns and its tool's model call, as one bill: three calls.
|
|
635
|
+
const { billed } = await meteredRun(() => reviewDraft({ postId: draft.id, orgId: draft.orgId }));
|
|
636
|
+
console.assert(billed?.calls === 3 && model.seen.length === 3);
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
| Turn | The provider answers |
|
|
640
|
+
|---|---|
|
|
641
|
+
| `{ text }` | prose, `end_turn`: the JSON-in-text path, or a repair turn when it is not JSON |
|
|
642
|
+
| `{ call, input? }` | one tool call, `tool_use` |
|
|
643
|
+
| `{ calls: [{ call, input? }, …] }` | several tool calls in one turn |
|
|
644
|
+
| `{ answer }` | a `respond` call: the structured answer |
|
|
645
|
+
| any of them `+ { stop }` | that stop reason instead, `'max_tokens'` for a cut-off turn |
|
|
646
|
+
|
|
647
|
+
One call past the last turn is `X_AI_SCRIPT_EXHAUSTED`, never the last turn again: a loop that never answers fails the test instead of spinning to `maxTurns` on a script that looked like it passed. `usage` fixes every turn's token counts; absent, they are estimated from the request, as echo does. `As of 2026-11`.
|
|
648
|
+
|
|
649
|
+
## Testing an island
|
|
650
|
+
|
|
651
|
+
One block per state the island's manifest declares. `describeIslandState` owns the lookup, the
|
|
652
|
+
mount (`beforeAll`, 60 s unless `timeoutMs` says otherwise) and the teardown; the island is built
|
|
653
|
+
once per file.
|
|
654
|
+
|
|
655
|
+
```ts
|
|
656
|
+
const island = { build: buildIslands, root: join(import.meta.dir, '..', '..', '..') };
|
|
657
|
+
|
|
658
|
+
describeIslandState(counterStates, 'idle', island, (mounted) => {
|
|
659
|
+
test('a click reaches the DOM through the signal', () => {
|
|
660
|
+
expect(mounted().fire('button', 'click')).toBe(true);
|
|
661
|
+
expect(mounted().text('[data-role="count"]')).toBe('1');
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
| | |
|
|
667
|
+
|---|---|
|
|
668
|
+
| `mounted()` | the block's mount — an accessor, because the block is declared before `beforeAll` runs |
|
|
669
|
+
| `shell` · `globals` · `size` | per block: the server's markup inside the wrapper, a `fetch` stub, the host's box |
|
|
670
|
+
| an id the manifest does not declare | `X_TEST_ISLAND_STATE_UNKNOWN`, listing the declared ones |
|
|
671
|
+
| `mountIslandState(manifest, id, options)` | the same lookup for one test's own mount: `using island = await mountIslandState(…)` |
|
|
672
|
+
|
|
673
|
+
The props are the manifest's, so the picture `x shot --island` takes and the test are of one
|
|
674
|
+
component. `mountIsland` stays the call for props no state declares. `As of 2026-10`.
|
|
675
|
+
|
|
676
|
+
## Testing an authenticator
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
unitTest('a session cookie resolves the member it was issued to', async () => {
|
|
680
|
+
const actor = await authenticate(...(await authRequest({ cookies: { session: token } })));
|
|
681
|
+
expect(actor?.id).toBe(member.id);
|
|
682
|
+
});
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
`authRequest({ url?, method?, headers?, cookies?, bearer? })` answers the two arguments
|
|
686
|
+
`configureAuthenticator()`'s function is handed — a real request over a real context, no server,
|
|
687
|
+
no cast. `cookies` and `bearer` win over the same header spelled in `headers`. `As of 2026-10`.
|
|
688
|
+
|
|
689
|
+
## Testing a passkey ceremony
|
|
690
|
+
|
|
691
|
+
```ts
|
|
692
|
+
import type { PasskeyLogin } from '@ultimat3/auth';
|
|
693
|
+
import { softAuthenticator } from '@ultimat3/auth/soft-authenticator';
|
|
694
|
+
|
|
695
|
+
declare const passkeys: PasskeyLogin; // passkeyLogin(defineAuth({ adapter, passkeys: { rpId, origins } }))
|
|
696
|
+
declare const userId: string;
|
|
697
|
+
|
|
698
|
+
const key = await softAuthenticator({ origin: 'https://app.test' });
|
|
699
|
+
const created = await key.create(await passkeys.registerOptions({ userId }));
|
|
700
|
+
await passkeys.register({ userId, response: created });
|
|
701
|
+
const session = await passkeys.login({ response: await key.get(await passkeys.loginOptions()) });
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
`softAuthenticator` is a passkey with real WebCrypto keys (ES256 or RS256): `create(options)`
|
|
705
|
+
answers what `newPasskey` would in a browser, `get(options)` what `getPasskey` would, so the test
|
|
706
|
+
runs the server's real verification. One implementation and one path, `@ultimat3/auth/soft-authenticator`, the one
|
|
707
|
+
auth's own suites use. `As of 2026-11`.
|
|
708
|
+
|
|
709
|
+
## Testing a sign-in against an identity provider
|
|
710
|
+
|
|
711
|
+
`As of 2026-11`. `fakeIdentityProvider({ users, issuer?, clientId?, clock? })` is an OpenID provider
|
|
712
|
+
served through `mockFetch`, no socket: `/.well-known/openid-configuration`, a JWKS from a key it
|
|
713
|
+
generates, `/authorize` approving as the chosen user, `/token` (PKCE checked) and `/userinfo`.
|
|
714
|
+
|
|
715
|
+
```ts
|
|
716
|
+
import { discoverOAuthProvider, registerOAuthProvider } from '@ultimat3/auth';
|
|
717
|
+
import { expect, fakeIdentityProvider, unitTest } from '@ultimat3/testing';
|
|
718
|
+
|
|
719
|
+
unitTest('the provider registers from its own discovery document', async () => {
|
|
720
|
+
const idp = await fakeIdentityProvider({ users: [{ sub: 'ada', email: 'ada@corp.test' }] });
|
|
721
|
+
const provider = registerOAuthProvider(
|
|
722
|
+
await discoverOAuthProvider({ id: 'corp', issuer: idp.issuer }),
|
|
723
|
+
);
|
|
724
|
+
expect(provider.jwksUri).toBe(idp.jwksUri);
|
|
725
|
+
idp.tamper({ wrongKey: true }); // every token from here on fails the signature check
|
|
726
|
+
});
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
| Member | Does |
|
|
730
|
+
|---|---|
|
|
731
|
+
| `issuer` · `clientId` · `clientSecret` · `jwksUri` | what the app's provider registration and credentials take; `issuer` defaults to `https://idp.test`, `clientId` to `fake-client` |
|
|
732
|
+
| `signInAs(sub)` | who `/authorize` approves as next; the first user until called |
|
|
733
|
+
| `tamper({ wrongAudience?, expired?, wrongKey?, noEmailVerified? })` | every token minted after it is broken that way; `tamper({})` restores honesty |
|
|
734
|
+
| `idToken({ sub, nonce? })` | a signed id token, for a test that skips the redirect leg |
|
|
735
|
+
|
|
736
|
+
Each tamper is refused by `@ultimat3/auth`'s real `verifyIdToken` with the code a real provider's
|
|
737
|
+
broken token gets (`packages/testing/src/fake-identity-provider.test.ts`). An unknown `sub` and an
|
|
738
|
+
empty `users` are `X_INVARIANT`.
|
|
739
|
+
|
|
740
|
+
## Log lines under test
|
|
741
|
+
|
|
742
|
+
| `LOG_LEVEL` | The terminal shows | A `setLogSink` a test installs receives |
|
|
743
|
+
|---|---|---|
|
|
744
|
+
| unset | nothing | `info` and above |
|
|
745
|
+
| `info` · `debug` · `trace` | that level and above | that level and above |
|
|
746
|
+
| `warn` · `error` · `fatal` · `silent` | that level and above | `info` and above, still |
|
|
747
|
+
|
|
748
|
+
`LOG_LEVEL=info bun test <file>` is how to read the lines. A level above `info` filters the
|
|
749
|
+
terminal and never the logger, so a test asserting on an `info` audit line passes under
|
|
750
|
+
`LOG_LEVEL=error`. `As of 2026-10`.
|
|
751
|
+
|
|
752
|
+
## An N+1 fails the test it happened in
|
|
753
|
+
|
|
754
|
+
`x dev` warns about a query loop; CI is where nobody is watching. Destructuring `statements` installs the same detector in **throw** mode for the length of one test:
|
|
755
|
+
|
|
756
|
+
```ts
|
|
757
|
+
test('the feed reads its authors once', async ({ statements }) => {
|
|
758
|
+
await renderFeed();
|
|
759
|
+
// X_N_PLUS_ONE_QUERY: members.findById ran 5 times in one request — one read per row
|
|
760
|
+
// fix: db.posts.preload('author') # one statement for the whole page
|
|
761
|
+
expect(statements.count('posts.findMany')).toBe(1);
|
|
762
|
+
});
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
The loop's fifth statement is what rejects, so the failing line is the loop's own — not a summary at teardown. Opting in is naming the fixture: there is no `strict: true` to remember and no switch left on for the next file.
|
|
766
|
+
|
|
767
|
+
| What | Rule |
|
|
768
|
+
|---|---|
|
|
769
|
+
| **The unit of work is the test** | not the request. A `posts.findById(id)` loop in a unit test has no request anywhere, and that is the loop worth catching |
|
|
770
|
+
| **Both drivers report** | `memoryRepo` reports one event per repository operation under Postgres' own `entity.op`, with no SQL text, `As of 2026-11-02`, so the loop fails on the driver unit tests run on. Stricter in one place: Postgres serves a sibling lookup off a page it already read (`jit-preload.ts`), and memory counts it |
|
|
771
|
+
| **The threshold is the dev one** | 5 statements of one shape, `N_PLUS_ONE_THRESHOLD` from `@ultimat3/entity`. A loop that fails a test and a loop that warns in `x dev` are the same loop |
|
|
772
|
+
| **The fix is the schema's** | the same `nPlusOne()` error `x dev` renders, so the `fix:` names the `preload()` your `references()` columns already spell |
|
|
773
|
+
| **Once per shape** | a body that catches the error gets one failure, not one per statement after it — and `shapes()` still reports the whole loop |
|
|
774
|
+
| **Measurement is not the verdict** | `all()`, `count()` and `shapes()` count every statement, including ones inside `expectedQueryLoop`; only the verdict honours the suppression |
|
|
775
|
+
|
|
776
|
+
A deliberate loop is declared where it is written, never silenced at the test:
|
|
777
|
+
|
|
778
|
+
```ts
|
|
779
|
+
await expectedQueryLoop('one indexed lookup per searchable field', () => searchEachField(term));
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
## Factories
|
|
783
|
+
|
|
784
|
+
One row shape per entity, deterministic, with named variations. `defineFactory` is the only entry point — there is no bare `factory()`.
|
|
785
|
+
|
|
786
|
+
```ts
|
|
787
|
+
import { associate, defineFactory } from '@ultimat3/testing';
|
|
788
|
+
import { boolean, entity, integer, text, uuid } from '@ultimat3/entity';
|
|
789
|
+
|
|
790
|
+
const org = entity('orgs', { columns: { id: uuid().primaryKey(), name: text() } });
|
|
791
|
+
const post = entity('posts', {
|
|
792
|
+
columns: {
|
|
793
|
+
id: uuid().primaryKey(),
|
|
794
|
+
orgId: uuid().references(() => org.id).tenant(), // the column `associations` fills
|
|
795
|
+
title: text(),
|
|
796
|
+
published: boolean().default(false),
|
|
797
|
+
views: integer().default(0),
|
|
798
|
+
},
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
const orgFactory = defineFactory(org, {
|
|
802
|
+
defaults: (index, ids) => ({ id: ids.uuid(), name: `org-${index}` }),
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
const postFactory = defineFactory(post, {
|
|
806
|
+
// `defaults` names every column; `associations` replaces `orgId` with the parent's id.
|
|
807
|
+
defaults: (index, ids) => ({
|
|
808
|
+
id: ids.uuid(),
|
|
809
|
+
orgId: '',
|
|
810
|
+
title: `post-${index}`,
|
|
811
|
+
published: false,
|
|
812
|
+
views: 0,
|
|
813
|
+
}),
|
|
814
|
+
traits: {
|
|
815
|
+
published: { published: true },
|
|
816
|
+
popular: (index) => ({ views: index * 100 }),
|
|
817
|
+
},
|
|
818
|
+
associations: { orgId: associate(orgFactory, (row) => row.id) },
|
|
819
|
+
});
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
| Member | Does |
|
|
823
|
+
|---|---|
|
|
824
|
+
| `build(over?)` | an in-memory row. Pure — no database, no persister |
|
|
825
|
+
| `buildMany(count, over?)` | the same, `count` times |
|
|
826
|
+
| `create(over?)` | builds, creates its association parents, then persists. Returns the row |
|
|
827
|
+
| `createMany(count, over?)` | the same, `count` times |
|
|
828
|
+
| `with(...traits)` | a view with those traits applied |
|
|
829
|
+
| `traits` | the declared trait names, sorted |
|
|
830
|
+
| `table` | the entity's table |
|
|
831
|
+
| `reset()` | restarts the sequence and both generators, and **cascades to every association** |
|
|
832
|
+
|
|
833
|
+
### Traits
|
|
834
|
+
|
|
835
|
+
A trait is a `Partial<Row>` or a `(index, ids) => Partial<Row>`. Merge order is: defaults, then traits in the order applied, then the call's own `over` — last wins.
|
|
836
|
+
|
|
837
|
+
A view from `with()` **shares the base sequence**, so ids never repeat across views of one factory. An unknown trait throws `X_TEST_FACTORY_TRAIT_UNKNOWN` **at the `with()` call**, not three calls later, and `cause` lists every declared trait — a typo and a trait nobody added have the same symptom, and the list is what separates them.
|
|
838
|
+
|
|
839
|
+
### Associations
|
|
840
|
+
|
|
841
|
+
A column whose value comes from another factory, built with the **same strategy**: `build()` builds the parent, `create()` creates it. Parents are created **sequentially, never `Promise.all`** — concurrent parents would interleave draws from the shared seeded generators and the ids would stop being reproducible. An association is skipped entirely when the caller supplies that column.
|
|
842
|
+
|
|
843
|
+
### Determinism
|
|
844
|
+
|
|
845
|
+
`seed` defaults to a hash of the **table name**, so every table has its own uuid stream while staying a pure function of the schema. `As of 2026-08` that is the 1.1.0 fix: every registry factory used to default to `seed: 1`, so two tables minted the same uuid and a join assertion could pass for the wrong reason.
|
|
846
|
+
|
|
847
|
+
`ids.uuid()` and `ids.number()` come from the seeded generators; `index` starts at 1 for the first row.
|
|
848
|
+
|
|
849
|
+
`factoriesFor(registry, seed?)` derives a factory per entity, each value read off the column's **declaration**, never its name (`As of 2026-11`, 30.0.0 entry 89): the declared default first, else an enumerated value, else what the kind offers (uuid, text, integer, boolean, timestamp, money as `{ minor, currency }`, …), and `null` only where the column is nullable. Every candidate goes through the column's own `$parse`, so a built row is one the table stores. A registry entry whose `$columns` are not `entity()` columns, or a column that refuses every generated value (a `json(t.object(…))` with required keys), throws `X_SEED_GRAPH_UNSATISFIED` at `build()`, naming the `defineFactory` to write. Source: `packages/testing/src/factory-registry.ts`, `seed-graph-values.ts`.
|
|
850
|
+
|
|
851
|
+
### Persisting
|
|
852
|
+
|
|
853
|
+
`create()` needs a persister; there is one seam and it is process-global.
|
|
854
|
+
|
|
855
|
+
```ts
|
|
856
|
+
// scripts/test-setup.ts
|
|
857
|
+
usePersister({ insert: (table, row) => repoFor(table).insert(row) });
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
Without one, `create()` throws `X_TEST_FACTORY_NOT_PERSISTED` — which means **nothing was attempted**, distinct from an insert that failed. `build()` needs no persister. `clearPersister()` and `persisterInstalled()` round out the seam.
|
|
861
|
+
|
|
862
|
+
### A whole graph: `seedGraph`
|
|
863
|
+
|
|
864
|
+
Rows for every table, with no factory written. Pure: it returns a plan, `insertSeedGraph` writes it.
|
|
865
|
+
|
|
866
|
+
```ts
|
|
867
|
+
import { entity, enumerated, text, uuid } from '@ultimat3/entity';
|
|
868
|
+
import { insertSeedGraph, type SeedWriter, seedGraph } from '@ultimat3/testing';
|
|
869
|
+
|
|
870
|
+
const orgs = entity('orgs', { columns: { id: uuid().primaryKey(), name: text({ max: 80 }) } });
|
|
871
|
+
const posts = entity('posts', {
|
|
872
|
+
columns: {
|
|
873
|
+
id: uuid().primaryKey(),
|
|
874
|
+
orgId: uuid()
|
|
875
|
+
.references(() => orgs.id)
|
|
876
|
+
.tenant(),
|
|
877
|
+
status: enumerated(['draft', 'published'] as const),
|
|
878
|
+
},
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
const graph = seedGraph({ orgs, posts }, { tenants: 2, perTable: 3, seed: 7 });
|
|
882
|
+
// graph.order is ['orgs', 'posts']: one org per tenant, three posts in each
|
|
883
|
+
|
|
884
|
+
declare const writer: SeedWriter; // insert, update and snapshot, over your driver
|
|
885
|
+
await insertSeedGraph(graph, writer);
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
| Rule | What it does |
|
|
889
|
+
|---|---|
|
|
890
|
+
| order | parents before children, over the entities' own `$references()` |
|
|
891
|
+
| tenancy | each child points at a parent of its **own** tenant; the entity a tenant column references holds one row per tenant, keyed by the tenant. A table with no tenant column whose references reach a tenant-owned one, and no `shared` reason, is seeded per tenant too, under its own tenant's parent — `row.tenant` is that parent's tenant |
|
|
892
|
+
| a cycle | broken at a nullable key: inserted `null`, set by an entry of `graph.updates` once its target exists. A cycle with no nullable key is `X_SEED_GRAPH_UNSATISFIED` |
|
|
893
|
+
| values | read off the declaration, as `factoriesFor` reads them, and kept only if `$parse` accepts them |
|
|
894
|
+
| rules | every invariant, the primary key, unique columns and unique indexes are kept; a partial unique is treated as total. A unique column that runs out of values ends its table early — but a table-wide unique that runs out before every tenant holds a row is `X_SEED_GRAPH_UNSATISFIED` (`… runs out of values before tenant 2 of 2 holds a row`): that tenant's children would have no parent |
|
|
895
|
+
| an unseeded target | a key — the tenant column included — whose target entity is not in the set is `X_SEED_GRAPH_UNSATISFIED` (`… which is not in the set seeded — pass it too`) |
|
|
896
|
+
| soft delete | a soft-deleting table's `deletedAt` is `null` unless a factory builds it: seeded rows are live |
|
|
897
|
+
| `factories` | `{ factories: { posts: myFactory } }` — what the factory builds wins, except keys and the tenant column, which stay the graph's |
|
|
898
|
+
| determinism | defaults `tenants: 2`, `perTable: 3` (per tenant for a tenant-scoped table), `seed` the package's `DEFAULT_SEED`. Same seed, same bytes |
|
|
899
|
+
| canaries | each row's generated text carries a token; `graph.canaries` lists it per row with the columns holding it, so a later read can say whose row it saw |
|
|
900
|
+
| writing | `insertSeedGraph(graph, writer)`: every insert in order, then every update, sequentially. `entitySeedWriter({ entities, client })` (Postgres or PGlite) or `entitySeedWriter({ entities, driver? })` (a driver; default `defaultDriver()`) writes through each entity's own repository. A row whose entity the writer was not given is `X_SEED_GRAPH_ENTITY_UNKNOWN` |
|
|
901
|
+
| `snapshot(rows)` | **required** on a `SeedWriter`: one digest per row, read back by primary key, in the order given; `null` for a row gone. Soft-deleted rows are read too. `entitySeedWriter` answers `md5(t::text)` per row over a client — the whole stored row, undeclared columns included — and an md5 of the row's sorted JSON over a driver. The tenant proof's write half |
|
|
902
|
+
|
|
903
|
+
`X_SEED_GRAPH_UNSATISFIED` names the table, the columns to supply and the `defineFactory` call. `As of 2026-11-05`, `seed-graph.live.test.ts` inserts both tracked apps' schemas into Postgres with no factory, and `seed-graph-pglite.test.ts` the same into PGlite, in the unit step. Source: `packages/testing/src/seed-graph*.ts`.
|
|
904
|
+
|
|
905
|
+
## Shared examples
|
|
906
|
+
|
|
907
|
+
One contract, asserted against many subjects, with the subject passed as a **thunk** so a `beforeAll`-built value is read at test time rather than at declaration time.
|
|
908
|
+
|
|
909
|
+
```ts
|
|
910
|
+
const anAuthenticatedAction = sharedExamples<Denier>('an authenticated action', (subject) => {
|
|
911
|
+
test('denies an anonymous actor', () => {
|
|
912
|
+
expect(subject().denies('anonymous')).toBe(true);
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
describe(testName('unit', 'publishPost'), () => {
|
|
917
|
+
behavesLike(anAuthenticatedAction, () => publishPost);
|
|
918
|
+
});
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
`behavesLike` wraps the body in `describe('behaves like <name>')`, so a failure reads `publishPost > behaves like an authenticated action > denies an anonymous actor` — the subject and the contract both named. Because it calls `describe`, it belongs at declaration scope and never inside a test body. The body runs once per `behavesLike` call.
|
|
922
|
+
|
|
923
|
+
Two contracts ship as shared examples, for the ports an app may implement itself: `behavesLike(jobDriverConformance, () => myDriver)` holds a custom `JobDriver` to claim / ack / nack, the claim fence, lease expiry and the burial of a row whose lease lapsed on its final attempt (`onExhausted`, `dropExhausted`); `behavesLike(budgetStoreConformance, () => myStore)` races 24 concurrent `take`s on one key against an `@ultimat3/ai` `BudgetStore`. The memory and Postgres job drivers and `memoryBudgetStore()` run the same suites in `@ultimat3/testing`'s own tests.
|
|
924
|
+
|
|
925
|
+
## Generated scaffolds
|
|
926
|
+
|
|
927
|
+
Every generator writes the tests beside what it wrote, and the `unit` half RUNS the code — the
|
|
928
|
+
coverage floor counts that suite alone, so a body only a typed suite reaches is uncovered source.
|
|
929
|
+
`As of 2026-10`.
|
|
930
|
+
|
|
931
|
+
| Generator | `unit` (in process, in-memory driver) | Its typed suite |
|
|
932
|
+
|---|---|---|
|
|
933
|
+
| `x g action` | input parse; the handler through `.as(actor, input)` — the refusal first, then what it answers | `contract`: the action contract (the exercise as a member of the input's org), the foreign-org denial, the OpenAPI operation |
|
|
934
|
+
| `x g mutator` | the same, plus `local()` against `memoryLocalTx()`, applied twice | `contract` |
|
|
935
|
+
| `x g query` | the SQL's bound, total order and direction; the read through `.as()` — no grant refused, an empty org reads empty, stored rows read back newest first and never another org's, the read costing one `statements` count however many rows it answers | `--live`: the declaration in `.live.test.ts` |
|
|
936
|
+
| `x g job` · `x g task` | the body under `runJobs`: which steps one run takes, what it answers, and how it ends | `job`: key, tenant, retry policy, enqueue dedupe |
|
|
937
|
+
| `x g backfill` | one whole pass over rows in two tenants; the projection replayed | `job`: durable name, dedupe |
|
|
938
|
+
| `x g route` | the page rendered (`renderRoute`): heading, metadata, mode, budget, islands | `e2e`: the offline fallback |
|
|
939
|
+
| `x g resource` | every row above for its slice, plus `page.test.ts`: the page rendered as the request's actor — the org's rows newest first beside the form island, the empty state, a member of another org reading none of them, the refusal rendered inside the page, and the list sorted and paged by link; `page-db.test.ts` asks the same sort and pages of the app's migrated database (`migratedDatabase` + `setDbClient`), the read's SQL path | `e2e`: the offline fallback |
|
|
940
|
+
| `x g island` | a `describeIslandState` block per state the `.island.states.ts` declares | — |
|
|
941
|
+
| `llm` prompt | — | `eval`: an evals file (missing evals fails `x verify`) |
|
|
942
|
+
|
|
943
|
+
A stored-row case is written only into a slice whose entity still has the columns `x g entity`
|
|
944
|
+
scaffolds; one an author reshaped gets the cases that need no row.
|
|
945
|
+
|
|
946
|
+
## The exercise assertion
|
|
947
|
+
|
|
948
|
+
`.contract()`'s fourth assertion calls the handler with generated input its own schema accepts.
|
|
949
|
+
A legal input that crashes the handler is red in `contract`, the day the action is written.
|
|
950
|
+
`As of 2026-11`.
|
|
951
|
+
|
|
952
|
+
| Rule | |
|
|
953
|
+
|---|---|
|
|
954
|
+
| Who acts | `action.contract({ exercise: { actor } })`. Required unless the policy admits an anonymous caller. There is no bypass: a policy that denies `actor` makes an unreached run |
|
|
955
|
+
| A row | `{ exercise: { actor, input: (drawn) => ({ ...drawn, postId }) } }` binds what a generated uuid cannot resolve |
|
|
956
|
+
| Opting out | `{ exercise: { off: '<reason>' } }`. The reason is in the test's name. A blank one is `X_CONTRACT_EXERCISE_UNDECLARED` |
|
|
957
|
+
| Runs | 16 (`EXERCISE_RUNS` from `@ultimat3/core`), `runs` from 1 to 64. Runs 0-3 sit on the IR's bounds (`minLength`, `maxLength`, `minimum`, `maximum`, `minItems`). The rest draw multibyte text, every enum and union member, optional keys on and off, `null` where nullable. A format value (`t.uuid`, `t.email`, …) is never varied |
|
|
958
|
+
| Seed | `fnv1a(<action name>)`, never time, so a red run replays: `bun test <file> -t '<name>: handler survives generated input'` |
|
|
959
|
+
| Rolled back | each run inside a memory transaction, always, and in it `withTransaction` when SQL can hold the writes: a SAVEPOINT in a test's open transaction, a root transaction once `setDbClient` installed a client. Both always roll back. The handler's SQL writes, its memory-store writes (`storeMode` `memory`, `tx` passed or not, since `memoryTransactor().run` is ambient), the outbox stage and the post-commit bust drop with it (`packages/action/src/tx-scope.ts` `inRolledBackScope`) |
|
|
960
|
+
| **Not rolled back** | a write on a second client or `outsideTransaction`. A job enqueued with `outbox: false`. Storage puts. Mail and outbound fetch: the test preload seals both, so a real one is only sent where a test opted out. Rate-limit counters. An audit record a non-database sink accepted |
|
|
961
|
+
| Where it runs | `development` and `test` only. `staging` or `production` (`ULTIMATE_ENV`, else `NODE_ENV`) is `X_CONTRACT_EXERCISE_ENVIRONMENT` before the first run: generated input never reaches a deploy. `bun test` sets `NODE_ENV=test` |
|
|
962
|
+
| Pass | the output parsed, or a coded refusal under 500 |
|
|
963
|
+
| Defect, `X_CONTRACT_DEFECT` | an uncoded throw, `X_OUTPUT_INVALID`, or a coded 5xx (an undeclared `errors:` code is one). `meta.run`, `meta.input`, `meta.failure` |
|
|
964
|
+
| Red, `X_CONTRACT_EXERCISE_UNREACHED` | no run reached the handler. `X_TEST_NETWORK_SEALED` counts as unreached, its host in `meta.outcomes` |
|
|
965
|
+
| Red, `X_CONTRACT_DRIFT` | the schema refused every draw: a provider refinement the IR does not carry. Bind it with `exercise.input` |
|
|
966
|
+
|
|
967
|
+
A query has the same assertion: `orgFeed.contract({ exercise: { actor } })`, named
|
|
968
|
+
`<name>: read survives generated input`. "Reached" is `sql()` building a source, and a read
|
|
969
|
+
writes nothing, so no transaction wraps it; it refuses a deploy the same way. Both are one loop,
|
|
970
|
+
`@ultimat3/core`'s `runExercise`: the verdicts above are the same words for a read and a write.
|
|
971
|
+
The generators emit the call with an actor (`x g action`, `x g resource`), holding the slice's grant: a hand-written `policy.ts`'s one literal
|
|
972
|
+
`can('<perm>')`, or `{ off }` when its grant cannot be read.
|
|
973
|
+
`@ultimat3/schema/sample`'s `arbitrary(schema, { seed, run })` is the draw, off the barrel.
|
|
974
|
+
|
|
975
|
+
## The exercise pass
|
|
976
|
+
|
|
977
|
+
The exercise assertion asks of one action, in a test someone wrote. `x exercise` asks of the whole
|
|
978
|
+
app, with no test: every action, query and job, run once against generated rows. Same option
|
|
979
|
+
shape, same unreached code. `As of 2026-11`.
|
|
980
|
+
|
|
981
|
+
| Rule | |
|
|
982
|
+
|---|---|
|
|
983
|
+
| Database | a scratch one with the framework tables and every migration applied; never the developer's. The pass commits, so the tenant proof can read rows after the call |
|
|
984
|
+
| Rows | one seed graph for the pass, a fresh pair of tenants per primitive in it — the first the actor's (A), the second the tenant proof's intruder (B); every table seeded off its declaration, parents first, same-tenant keys (`x db seed --generate`'s graph) |
|
|
985
|
+
| Actor | the first of `x policy explain`'s actors the policy allows, in the primitive's tenant. None allowed is `refused`, by design |
|
|
986
|
+
| Input | the override, then an id field whose stem names a seeded entity (`postId` → `posts`), then the schema's minimal draw. A wrong guess shows as `unreached` |
|
|
987
|
+
| Async input | an override's `input` may be `async`: it is awaited inside the call's request, as the call's actor, so it can mint what the action checks (a one-time token, a passkey assertion). `x drill`'s load skips an `async` input and says why |
|
|
988
|
+
| Room | network sealed, mail captured, jobs in memory, clock frozen. A call out is refused and counted, so the primitive is `unreached` |
|
|
989
|
+
| Overrides | `apps/web/exercise.ts`: `export const exercise = defineExercise({ publishPost: { actor, input }, sendInvoice: { off: '<why>' } })`. Each value is `.contract({ exercise })`'s option. An unknown name is `X_DECLARATION_UNKNOWN`; a blank `off` is `X_CONTRACT_EXERCISE_UNDECLARED` |
|
|
990
|
+
| Report | `.x/exercise.json` and `--json`: `{ seed, counts, records[] }`, each record `{ kind, name, outcome, code?, reason?, statements, effects, ms, override?, tenant?, crash? }`. The fields before `tenant` are A's call. The human table's `tenant` and `crash` columns are the verdicts, `-` where none ran |
|
|
991
|
+
| Gate | the `exercise` step, after `job`, runs `x exercise --json` as a child and is red on its findings: `X_CONTRACT_EXERCISE_UNREACHED`, `X_TENANT_LEAK`, `X_TENANT_WRITE_CROSSED`, `X_JOB_CRASH_DIVERGED`, and — on a whole pass, not `x exercise <name>` — `X_ENTITY_TENANT_UNDECLARED`. `unreached` is never sampled or skipped: it is a finding whose `fix:` is the entry to paste |
|
|
992
|
+
|
|
993
|
+
```bash
|
|
994
|
+
x exercise --json # the whole pass
|
|
995
|
+
x exercise publishPost --json # one primitive, after pasting its override
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
### The tenant proof
|
|
999
|
+
|
|
1000
|
+
**Every action, query and job is attacked from another tenant, around its own call**, `As of
|
|
1001
|
+
2026-11`. Tenant B, holding the same role, sends exactly A's first input — A's seeded ids bound in.
|
|
1002
|
+
Source: `packages/testing/src/tenant-proof.ts`, `tenant-canary.ts`, `tenant-proof-report.ts`.
|
|
1003
|
+
|
|
1004
|
+
| Leg | Who | Why there |
|
|
1005
|
+
|---|---|---|
|
|
1006
|
+
| `before` | B, with A's ids | a deleting action would leave B nothing to reach after A ran |
|
|
1007
|
+
| control | A, its own call — the pass's ordinary run, the record's `outcome` | the positive control: a B that saw nothing proves only that A's call works |
|
|
1008
|
+
| `after` | B again, same input | catches a cache entry A filled under a key missing the tenant |
|
|
1009
|
+
|
|
1010
|
+
| Half | Oracle |
|
|
1011
|
+
|---|---|
|
|
1012
|
+
| read | B's output, every captured mail (addresses, subject, text, html, unsubscribe url), every outbound call's url and body, every job B's call queued (its input; the primitive's own job excepted) — searched for a mark of A's: a row's canary, or a seeded key part ≥ 8 characters (`MIN_TAINT_ID_LENGTH`) no other tenant's row shares. A mark B's own input carried is no find: echoing the id B was handed proves nothing. Object keys are searched too |
|
|
1013
|
+
| write | `snapshot()` over every row seeded for A — a child with no tenant column included — before and after each B leg. A digest that moved, or a row gone, is crossed |
|
|
1014
|
+
|
|
1015
|
+
B's actor is chosen as A's was, in B's tenant, with `tenancy:cross` stripped from its scopes: an
|
|
1016
|
+
attack holding the escape would only prove the escape works. B being refused is never judged —
|
|
1017
|
+
only what came back and what changed.
|
|
1018
|
+
|
|
1019
|
+
| Verdict | When — first match wins | Red |
|
|
1020
|
+
|---|---|---|
|
|
1021
|
+
| `crossed` | any finding, from either leg | yes: `X_TENANT_LEAK` (a path, `output.items[0].url`, `effect.mail[0].to`) or `X_TENANT_WRITE_CROSSED` (table and A's row id) |
|
|
1022
|
+
| `unproven` | A's own call was not `reached` | no — the record is already `unreached`, which is |
|
|
1023
|
+
| `public` | the policy admits an anonymous caller with an empty input: read half skipped, write half kept | no |
|
|
1024
|
+
| `public` | a **job** whose B leg opened `crossTenant(reason, fn)` and whose input carried none of A's marks — a declared sweep, `sendDigest` fanning out one delivery per org. The reason is quoted in the verdict; read half skipped, write half kept | no |
|
|
1025
|
+
| `unproven` | a query whose A answer held none of A's marks: B's empty answer proves nothing | no |
|
|
1026
|
+
| `proven` | otherwise | no |
|
|
1027
|
+
|
|
1028
|
+
`off` and `refused` records carry no `tenant`. A job has no policy, so it is `public` only as a
|
|
1029
|
+
declared sweep. `crossTenant()` is the one declaration the proof reads (`@ultimat3/entity` tells a
|
|
1030
|
+
witness each scope opened, after its capability is proven); it excuses nothing on an action or a
|
|
1031
|
+
query, which answer B directly, nor on a job B handed one of A's ids — that is B choosing A's row,
|
|
1032
|
+
crossed as ever. A finding prints paths, tables and seeded ids, never a bound value. The fix on each:
|
|
1033
|
+
`x exercise <name> --json`, then read or write the row through the repository scoped to
|
|
1034
|
+
`ctx.actor`. What this does not walk — a `route` `load`, a `task` — is
|
|
1035
|
+
[Policies and authz](Policies-And-Authz#the-dynamic-tenant-proof).
|
|
1036
|
+
|
|
1037
|
+
### The crash proof
|
|
1038
|
+
|
|
1039
|
+
**Every job the pass reached is killed at every step boundary**, `As of 2026-11`: `crashProof`
|
|
1040
|
+
([above](#a-job-killed-at-every-boundary-and-raced-from-a-seed)) with the control's own input and
|
|
1041
|
+
actor, after the tenant proof. Source: `packages/testing/src/exercise-crash.ts`,
|
|
1042
|
+
`packages/cli/src/exercise-findings.ts`.
|
|
1043
|
+
|
|
1044
|
+
| Rule | |
|
|
1045
|
+
|---|---|
|
|
1046
|
+
| Runs | committed on the pass's scratch database, after the control. A job body runs outside every transaction, so no rollback scope can hold its writes, and an effect is counted when it commits |
|
|
1047
|
+
| Oracle | the effect multiset — row writes through a repository, mail, calls out, enqueues — of each killed run against the clean one. Raw SQL is no effect, and reads are none |
|
|
1048
|
+
| A job with no step | one boundary, before the ack: an effect in its body runs again on the retry |
|
|
1049
|
+
|
|
1050
|
+
| `crash.verdict` | When | Red |
|
|
1051
|
+
|---|---|---|
|
|
1052
|
+
| `diverged` | an effect made more often by a killed run: `findings[]` of `{ boundary, mode, effect, clean, killed, after? }` | yes: `X_JOB_CRASH_DIVERGED`, one per effect and the step it follows, every boundary named |
|
|
1053
|
+
| `held` | otherwise; `boundaries` and `reruns` (lost answers — at-least-once, never red) counted | no |
|
|
1054
|
+
| `unproven` | the proof itself threw: `reason` | no |
|
|
1055
|
+
|
|
1056
|
+
The human output prints one line per killed boundary,
|
|
1057
|
+
`job chargeOrder · killed at mail · X_JOB_CRASH_DIVERGED row:payments:insert 2× (clean 1×)`. The fix
|
|
1058
|
+
reruns the one job and names the effect to move:
|
|
1059
|
+
|
|
1060
|
+
```bash
|
|
1061
|
+
x exercise chargeOrder --json # in job("chargeOrder"): wrap the "row:payments:insert" made after step "create" in step.run('<name>', async () => …)
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
### The plan and scale sweeps
|
|
1065
|
+
|
|
1066
|
+
**After every primitive ran, two sweeps, with no flag to turn either off**, `As of 2026-11`.
|
|
1067
|
+
Source: `packages/testing/src/scale-sweep*.ts`, `plan-oracle.ts`, `plan-sweep*.ts`.
|
|
1068
|
+
|
|
1069
|
+
| Sweep | What | Red |
|
|
1070
|
+
|---|---|---|
|
|
1071
|
+
| scale | every reached primitive once more in a tenant at 2 rows a table, once in one at 8; per statement shape, the counts compared | a shape sent more often at 8: `X_QUERY_SCALES_WITH_ROWS`, fix `preload()` or `expectedQueryLoop('<why>', fn)` |
|
|
1072
|
+
| plans | every distinct statement whose first relation is an app table, `PREPARE`d and explained as a generic plan with `enable_seqscan`, `enable_bitmapscan`, `enable_sort` off, in a read-only transaction | `Seq Scan`, or an index scan with no `Index Cond` no `Limit` stops: `X_QUERY_UNINDEXED`. `Sort` under `Limit`: `X_QUERY_SORT_UNSERVED`. Each prints `indexes: [{ on: [...] }]` |
|
|
1073
|
+
|
|
1074
|
+
With the planner's scans and sorts disabled it picks one only when no index can answer, so the
|
|
1075
|
+
verdict reads the schema on empty tables — no volume, no statistics. Never red: `scale.verdict`
|
|
1076
|
+
`flat` (a declared loop listed in `expected` with its reason) or `unproven` (a call that did not
|
|
1077
|
+
answer at both sizes); a statement whose equality pins a unique key; one a partial index serves once
|
|
1078
|
+
its value is bound; one under `expectedSeqScan('<why>', fn)` or on an entity with `scan: '<why>'`
|
|
1079
|
+
(`plans.exempt`); one generic planning refuses (`plans.unplannable`). A server below Postgres 12:
|
|
1080
|
+
`X_QUERY_PLAN_UNAVAILABLE`.
|
|
1081
|
+
|
|
1082
|
+
```bash
|
|
1083
|
+
x db gen posts-orgId-createdAt-id-index # after indexes: [{ on: ['orgId', 'createdAt', 'id'] }] in entity("posts")
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
## Memory, width and isolation
|
|
1087
|
+
|
|
1088
|
+
`As of 22.7`. The gate is built for the machines it runs on — 8-16 GB, 2-8 cores, often two agents'
|
|
1089
|
+
gates at once — and it is held to a **memory budget**, not to whatever looks free.
|
|
1090
|
+
|
|
1091
|
+
- **Width.** `max(2.75 GiB, 25% of total RAM)` divided by 1.25 GiB a worker, clamped to `1..cores`:
|
|
1092
|
+
3 workers on a 16 GB box, 12 on a 62 GB 16-core one, 2 on an 8 GB one (the 2.75 GiB floor). Never more workers than cores. The step line says
|
|
1093
|
+
what it chose and why — `3 workers (budget 4.0 GB)`. `ULTIMATE_TEST_MEMORY_BUDGET=3g` replaces
|
|
1094
|
+
the budget, `ULTIMATE_TEST_MAX_WORKERS=2` caps the width, `--workers N` wins over both. Total RAM,
|
|
1095
|
+
never "free": page cache counts as free, and two gates each planning on it took a 45 GB box down.
|
|
1096
|
+
- **One budget per machine.** Each batch of workers leases that many slots from a pool of lock
|
|
1097
|
+
files under the OS temp dir (one per worker the budget allows; a dead holder's slot is taken
|
|
1098
|
+
over). A second `x test` or `x verify` on the same box gets what is left and runs narrower, or
|
|
1099
|
+
waits for one slot, instead of doubling the memory. `ULTIMATE_TEST_SLOTS=0` turns it off.
|
|
1100
|
+
- **No per-file isolation by default.** A worker keeps one global and one module registry across
|
|
1101
|
+
the files it runs (`bun test --parallel=N --no-isolate`) — measured 2-5x faster than `--isolate`
|
|
1102
|
+
at the same width. Between two files the testing preload hands the next file the process it would
|
|
1103
|
+
have had alone: undisposed island mounts are disposed, `globalThis` and `process.env` go back to
|
|
1104
|
+
the first file's baseline, the permission/role/catalog registries are restored (as a union — a
|
|
1105
|
+
module imported once per worker declares once), tasks exactly (an `anonymous-task-<n>` or
|
|
1106
|
+
`anonymous-job-<n>` name is never re-minted in a process — `resetTasks()`/`resetJobs()` clear
|
|
1107
|
+
the registry, not the counter — so a later file's task cannot take an earlier one's seat), every
|
|
1108
|
+
`mock.module` (`vi.mock`, `jest.mock`) the file made is put back to the real module — Bun keeps
|
|
1109
|
+
one past `mock.restore()` and past the file — and `.tsx` always compiles with the app's JSX
|
|
1110
|
+
factory. The real module is loaded for its snapshot before the fake goes in, so its top-level code
|
|
1111
|
+
runs; one that throws there cannot be put back, and the next file on the worker fails
|
|
1112
|
+
`X_TEST_MODULE_MOCK_UNRESTORABLE` naming the file that mocked it. A repository whose tests need a fresh global per file says
|
|
1113
|
+
`"isolate": true` in `x.verify.json`, or passes `--isolate` to `x test` / `x verify`.
|
|
1114
|
+
The framework repository itself still says `true`, `As of 2026-11`: `ai mcp` in one shared
|
|
1115
|
+
process went from 66 failures to 0 and `auth mcp ai testing` reads 0 (a test file's top-level
|
|
1116
|
+
`definePermissions` is its worker's environment — declare in `beforeAll`); `cli` is unmeasured.
|
|
1117
|
+
- **One long-lived process per worker.** Without isolation a worker's memory is set by its live
|
|
1118
|
+
database, not by how many files it ran, so a pass is ONE `bun test --parallel=N` (recycled only
|
|
1119
|
+
past 256 files a worker; 24 under `--isolate`, whose heap grows per file). Every parallel run
|
|
1120
|
+
reads and refreshes `.x/test-timings.json`, so Bun starts the slowest files first.
|
|
1121
|
+
- **One database per worker.** `reusableDatabase(open)` from `@ultimat3/testing` opens an
|
|
1122
|
+
embedded database once per worker and resets its DATA to the template between files (truncate,
|
|
1123
|
+
re-insert the snapshot, reset sequences, triggers off meanwhile) — a PGlite boot from a migrated
|
|
1124
|
+
template costs 2.4-7 s, which a per-file database pays in every file.
|
|
1125
|
+
|
|
1126
|
+
Measured, `As of 2026-09-27`, 12-core box, whole process tree sampled every 200 ms:
|
|
1127
|
+
|
|
1128
|
+
| run | workers | wall | peak RSS |
|
|
1129
|
+
|---|---|---|---|
|
|
1130
|
+
| one adopter's `x test unit` (768 files), 22.6.2 default (isolated) | 14 (+parent) | 193 s | 13.1 GB |
|
|
1131
|
+
| the same, 22.7 default (16 GB+ box), test DB on `reusableDatabase` | 3 | 90-93 s | 3.7-3.9 GB |
|
|
1132
|
+
| the same, 2 workers (an 8 GB box's default) | 2 | 150 s | 2.6 GB |
|
|
1133
|
+
| framework `x test unit` (1620 files), no isolation | 3 | 115-125 s | 2.9-3.0 GB |
|
|
1134
|
+
| framework `bun run verify` (whole gate, `"isolate": true`) | 3 | 227 s | 3.4 GB |
|
|
1135
|
+
|
|
1136
|
+
The framework repository itself opts back into `"isolate": true`: its suites exercise the
|
|
1137
|
+
process-global registries on purpose (fixture apps per test, empty permission sets), and without
|
|
1138
|
+
isolation a handful of files fail depending on which worker ran what before them. Apps do not
|
|
1139
|
+
need it — one adopter's unit, contract and job tiers pass without isolation.
|
|
1140
|
+
|
|
1141
|
+
A worker on the embedded database (PGlite) cannot go much below 1 GB: one booted PGlite holds
|
|
1142
|
+
0.9-1.1 GB RSS even after `close()` and a full GC.
|
|
1143
|
+
|
|
1144
|
+
For CI, where wall time matters more than one box's memory, the gate splits across jobs:
|
|
1145
|
+
[CI: the gate across parallel jobs](CI-Parallel-Gate).
|
|
1146
|
+
|
|
1147
|
+
## Coverage
|
|
1148
|
+
|
|
1149
|
+
95% of lines and 95% of functions — one bar, for the framework's packages and for every app.
|
|
1150
|
+
Held by the `unit` step: lint and tests alone are not green.
|
|
1151
|
+
|
|
1152
|
+
| What | Rule |
|
|
1153
|
+
|---|---|
|
|
1154
|
+
| Measured over | every `.ts` / `.tsx` under the app's `apps/` and `packages/`. Not tests, `.d.ts`, `node_modules`, `dist`, `build`, `.x` |
|
|
1155
|
+
| A file no unit test loads | counts at **0%**, weighed off its source. Bun's own summary averages loaded files only — `dummy/social-media-clone` read 91.7% of lines that way and is 67.5% over its whole tree (24 files nothing loads, `As of 2026-10-01`) |
|
|
1156
|
+
| A barrel, a types-only module | weighs nothing: it emits no code |
|
|
1157
|
+
| Which suite | `unit` only. An opt-in suite cannot hold a floor every default run must meet |
|
|
1158
|
+
| The floor | `coverage` in `x.verify.json`. `x new` writes 95 / 95 |
|
|
1159
|
+
|
|
1160
|
+
```json
|
|
1161
|
+
{
|
|
1162
|
+
"steps": ["unit"],
|
|
1163
|
+
"coverage": {
|
|
1164
|
+
"lines": 95,
|
|
1165
|
+
"funcs": 95,
|
|
1166
|
+
"exclude": [{ "glob": "apps/*/server.ts", "why": "the container entry point" }]
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
| State | Finding on `unit` | Fix it carries |
|
|
1172
|
+
|---|---|---|
|
|
1173
|
+
| no `coverage` | `X_COVERAGE_FLOOR_UNSTATED` | the exact line to add, with the measured numbers |
|
|
1174
|
+
| a floor under 95 with no `why` | `X_COVERAGE_FLOOR_UNSTATED` | add `"why"`, or raise to 95 |
|
|
1175
|
+
| measured under the floor | `X_COVERAGE_BELOW_FLOOR` — both numbers, and the ten files losing the most lines | `bun test --coverage <the worst file's test>` |
|
|
1176
|
+
| a floor under 95, and the tree 1.5 points over it | `X_COVERAGE_FLOOR_STALE` | the numbers to write |
|
|
1177
|
+
| an `exclude` with no `why`, a number that is not 0–100 | `X_CONFIG_INVALID`, on `manifest` | the shape to write |
|
|
1178
|
+
|
|
1179
|
+
- **The floor only rises.** Under 95 it may be nothing but what the tree measures: more than 1.5
|
|
1180
|
+
points over it is red until the floor moves up. No history is read — an app with no git is held
|
|
1181
|
+
the same.
|
|
1182
|
+
- **`exclude` is for code a unit test cannot execute** — an island's browser mount (its test runs a
|
|
1183
|
+
built chunk), a container entry point. Each entry carries its `why`; `x doctor` prints them all.
|
|
1184
|
+
- **A floor, not the goal.** Coverage counts execution, not validation: 100% is reachable with zero
|
|
1185
|
+
assertions. A test added to raise it is proven by mutation — break the source, watch it go red,
|
|
1186
|
+
restore. A covered branch whose test cannot fail is worse than an uncovered one.
|
|
1187
|
+
- **Cost: none measured.** The step on `examples/dummy` (53 unit files): 7.5 s before, 7.5 s after
|
|
1188
|
+
(`As of 2026-10-01`).
|
|
1189
|
+
- **Same number on every machine.** The suite runs as fixed slices of 16 neighbouring test files,
|
|
1190
|
+
one plain `bun test` each; the lcov parts are folded here. Not `bun test --parallel --coverage`:
|
|
1191
|
+
Bun's own cross-worker merge read one tree as 59.9% at four workers and 65.0% at two.
|
|
1192
|
+
- **Functions** are counted per source file as the best one slice reached — Bun's lcov carries no
|
|
1193
|
+
function names to union. A directory's tests share a slice, so tests beside their source count
|
|
1194
|
+
together.
|
|
1195
|
+
- **Under `--shard`** a slice cannot hold a floor: it is green on coverage, carries its facts in
|
|
1196
|
+
`data.coverage.unit.facts`, and `x verify merge` judges the fold → [CI](CI-Parallel-Gate).
|
|
1197
|
+
- **In `--json`** a judged run carries `data.coverage.unit` — `{ "lines", "funcs", "files" }`. The
|
|
1198
|
+
human render prints the same line under the step with `--verbose`, and always when it is red.
|
|
1199
|
+
- **A `.tsx` is counted in its COMPILED lines — a Bun inaccuracy, `As of 2026-10` (Bun 1.4.0).**
|
|
1200
|
+
Bun reports a plugin-loaded file under its source path with the output's line numbers and
|
|
1201
|
+
totals, and every rendered `.tsx` is compiled by a plugin. So `Uncovered Line #s` for a `.tsx`
|
|
1202
|
+
is not a place in the source, and a view whose tests assert every branch can still read under
|
|
1203
|
+
100%. Read the percentage, ignore the line list, and keep logic that needs a line-accurate
|
|
1204
|
+
answer in a `.ts` module beside the view. The minimal case:
|
|
1205
|
+
[`docs/history/bun-coverage-plugin-lines.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/history/bun-coverage-plugin-lines.md).
|
|
1206
|
+
|
|
1207
|
+
The framework holds itself to the same constant: `bun run coverage` gates each package and
|
|
1208
|
+
`scripts/` alone, one process each (`scripts/coverage-gate.ts`).
|
|
1209
|
+
|
|
1210
|
+
## `x verify`
|
|
1211
|
+
|
|
1212
|
+
The single gate. Green means shippable.
|
|
1213
|
+
|
|
1214
|
+
21 steps, one list, in cost order. **The gate is this command with no flag** — "green" has to
|
|
1215
|
+
mean the same thing for everyone. `x verify --only <step>[,<step>…]` runs the named steps — several
|
|
1216
|
+
in one process — for an iteration loop and says `NOT A GATE RUN` in the summary and in `--json`
|
|
1217
|
+
(`data.notAGateRun`), writing no floor file;
|
|
1218
|
+
there is no `--skip`, because a knob that removes a step from a run still calling itself the gate is
|
|
1219
|
+
the one thing this command must not offer. A step with nothing to check reports as skipped (`-`),
|
|
1220
|
+
never as passed.
|
|
1221
|
+
|
|
1222
|
+
The list is defined once, as `VERIFY_STEP_NAMES` in
|
|
1223
|
+
[`packages/cli/src/verify-step.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/verify-step.ts).
|
|
1224
|
+
This table is a hand-synced copy of it ([Contributing](Contributing)).
|
|
1225
|
+
|
|
1226
|
+
| Step | Fails on |
|
|
1227
|
+
|---|---|
|
|
1228
|
+
| `typecheck` | any error; `any` is banned by lint, not tolerated by a cast |
|
|
1229
|
+
| `lint` | biome only: formatting, `any`, default exports, unused imports. **Not** raw colours, bare `Error`, untranslated strings or unzoned dates — biome ignores `.scss` entirely and those are guards, one row down |
|
|
1230
|
+
| `boundaries` | `site/` → `app/`, routes → DB, services → HTTP, tier violations in framework packages, **and every file in the app's own `guards/`** — every name in `SHIPPED_GUARD_NAMES` (`packages/cli/src/templates/scaffold-guards.ts`) that `x new` writes, or the ones you kept → [Interface rules](Interface-Rules) |
|
|
1231
|
+
| `filesize` | a source file over 500 lines |
|
|
1232
|
+
| `package-shape` | a workspace package missing `README.md`, `CLAUDE.md`, `tsconfig.json`, or `src/index.ts` |
|
|
1233
|
+
| `errors` | an `X_*` code with no runnable fix or no docs page |
|
|
1234
|
+
| `unit` | pure logic — services, money, policy predicates, matchers; in an app, coverage under the floor in `x.verify.json` → [Coverage](#coverage) |
|
|
1235
|
+
| `contract` | action/query schemas, policy denials, emitted OpenAPI and MCP shapes |
|
|
1236
|
+
| `live` | live-query snapshot, incremental patches, reconnect delta, policy-filtered rows |
|
|
1237
|
+
| `job` | step replay, idempotency dedupe, retry/backoff, concurrency, outbox atomicity |
|
|
1238
|
+
| `exercise` | an action, query or job that never reached its handler when `x exercise` ran it once on generated rows (`X_CONTRACT_EXERCISE_UNREACHED`, its fix the override to paste); one that handed tenant B tenant A's data or changed A's row (`X_TENANT_LEAK`, `X_TENANT_WRITE_CROSSED`); a job that, killed at a step boundary, made an effect twice (`X_JOB_CRASH_DIVERGED`); a tenantless child of a tenant-owned table (`X_ENTITY_TENANT_UNDECLARED`) → [The exercise pass](#the-exercise-pass). Skipped in a repo with no `app.config.ts` |
|
|
1239
|
+
| `e2e` | a real browser (raw CDP, no Playwright) against the app, spawned on a throwaway database, when Chrome is on the machine; otherwise its browser cases skip, or refuse under `E2E_BROWSER_REQUIRED=1` |
|
|
1240
|
+
| `eval` | a prompt scoring below its committed baseline, or a prompt with no eval at all |
|
|
1241
|
+
| `drift` | schema differs from migrations, or a migration is not reversible-or-marked |
|
|
1242
|
+
| `contract-diff` | a breaking change to a published action/query without a version bump |
|
|
1243
|
+
| `budgets` | per-route JS bytes; a live hook on a route with no island is `X_LIVE_ROUTE_NO_ISLAND` |
|
|
1244
|
+
| `seo` | an indexable `site/` route with no title, or no description a search result can render |
|
|
1245
|
+
| `i18n` | a key missing from a locale's catalog, or a catalog no module ever registered |
|
|
1246
|
+
| `policy` | a permission this app grants or requires that it does not declare. Skipped — never passed — in a repo with no `app.config.ts` |
|
|
1247
|
+
| `manifest` | `x.manifest.json` / `openapi.json` differ from what the code produces, `AGENTS.md` is missing or over its byte cap, or a job's `timeout` outlasts the worker's budget (`X_JOB_OUTLASTS_DRAIN`) |
|
|
1248
|
+
| `roadmap` | framework repo only — a milestone missing its status marker, or a shipped milestone missing an artifact its own row names |
|
|
1249
|
+
|
|
1250
|
+
Any failure fails the gate; flakes are failures.
|
|
1251
|
+
|
|
1252
|
+
```text
|
|
1253
|
+
$ x verify
|
|
1254
|
+
✓ typecheck ✓ lint ✓ boundaries ✓ filesize ✓ package-shape ✓ errors
|
|
1255
|
+
✓ unit ✓ contract ✓ live ✓ job ✓ exercise ✓ e2e ✓ eval
|
|
1256
|
+
✗ drift
|
|
1257
|
+
X_DB_DRIFT: schema differs from migrations
|
|
1258
|
+
cause: table "posts" has column "publish_at" not present in any migration
|
|
1259
|
+
fix: x db gen "add publish_at"
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
**A step has a deadline, and a hung one fails by name.**
|
|
1263
|
+
|
|
1264
|
+
| Step | Deadline | Past it |
|
|
1265
|
+
|---|---|---|
|
|
1266
|
+
| `unit` `contract` `live` `job` `e2e` `eval` | 8 minutes | `X_VERIFY_STEP_TIMEOUT` on that step; every process it started is killed and the test file still running is named; the steps after it still run |
|
|
1267
|
+
| every other step | 5 minutes | the same |
|
|
1268
|
+
| one you name | `"stepTimeoutMs": { "unit": 900000 }` in `x.verify.json` | the same |
|
|
1269
|
+
|
|
1270
|
+
**The declared numbers are a 4-core CI runner's.** `x verify` measures the machine once, before
|
|
1271
|
+
any step — its cores against 4, a short single-threaded probe against the runner's — and multiplies
|
|
1272
|
+
every declared deadline by that pace: each step's above, Bun's 5 s per test (`--timeout`), and a
|
|
1273
|
+
test's own `{ timeoutMs }` or `paced(ms)` (`ULTIMATE_TEST_PACE`). A faster machine is pace 1, never
|
|
1274
|
+
a tighter number. A number in `x.verify.json` is taken as written. `--json` carries the reading as
|
|
1275
|
+
`data.pace` — `{ factor, cpus, probeMs }` — and a timed-out step whose deadline was stretched says
|
|
1276
|
+
by how much.
|
|
1277
|
+
|
|
1278
|
+
**The gate says where it is.** In either render, one line per finished step on stderr —
|
|
1279
|
+
`{"step":"lint","ok":true,"ms":21987}` — so a cancelled CI job's log ends on the last step that
|
|
1280
|
+
finished, and a `still running: unit (12m 3s)` heartbeat every minute a step runs, so a harness
|
|
1281
|
+
that kills on silence never kills a slow but healthy step. Under `--json` stdout stays the one document.
|
|
1282
|
+
|
|
1283
|
+
`x verify --json` emits the same content machine-readably → [MCP and AI](MCP-And-AI). CI runs exactly `x verify` — no bespoke pipeline steps, because a check that lives only in CI is a check developers cannot run.
|
|
1284
|
+
|
|
1285
|
+
## Errors
|
|
1286
|
+
|
|
1287
|
+
| Code | Cause | Fix |
|
|
1288
|
+
|---|---|---|
|
|
1289
|
+
| `X_TEST_NETWORK_SEALED` | a test reached the network without a mock | `mockFetch('<url>', …)`, or `allowHost('<host>')` if the call must be real — both from `@ultimat3/testing`. There is no `x test mock` subcommand |
|
|
1290
|
+
| `X_FORBIDDEN` | the actor's policy refused — the assertion target of every denial test | `x actions show <action> --json` names the capability it enforces: assert the denial with `.rejects.toBeUltimateError('X_FORBIDDEN')`, or grant that capability to the seeded actor's role in `apps/web/shared/policies.ts` |
|
|
1291
|
+
| `X_INVARIANT` | a domain invariant was violated inside a test fixture | fix the seed or the invariant |
|
|
1292
|
+
| `X_CONFIG_INVALID` | test config names an unknown worker count, driver, or test type | `x test --help` |
|
|
1293
|
+
| `X_TEST_ISLAND_STATE_UNKNOWN` | an island test named a state its manifest does not declare | name one of the ids the cause lists, or declare the state in the `.island.states.ts` beside the island |
|
|
1294
|
+
| `X_COVERAGE_BELOW_FLOOR` | the unit suite covers less of the app than `x.verify.json` states | `x verify --only unit --json` names the ten worst files; cover the first with a test beside it |
|
|
1295
|
+
| `X_COVERAGE_FLOOR_UNSTATED` | no `coverage` in `x.verify.json`, or one under 95 with no `why` | the finding carries the line to add |
|
|
1296
|
+
| `X_COVERAGE_FLOOR_STALE` | a floor under 95 the tree has left 1.5 points behind | raise `coverage` to the numbers the finding carries |
|
|
1297
|
+
| `X_VERIFY_STEP_TIMEOUT` | a gate step ran past its deadline | `bun test <file>` when the finding names the stuck file; otherwise `x verify --only unit --json` reproduces the step alone |
|
|
1298
|
+
|
|
1299
|
+
Full list: [Error codes](Error-Codes).
|
|
1300
|
+
|
|
1301
|
+
## Rules
|
|
1302
|
+
|
|
1303
|
+
- Never mock the database. Clone it.
|
|
1304
|
+
- Never assert on wall-clock time. Advance the frozen clock.
|
|
1305
|
+
- Never let a test reach the network unmocked — it fails by design.
|
|
1306
|
+
- A flaky test is deleted or fixed the day it flakes. There is no `retry: 3`.
|
|
1307
|
+
- Hunting an order flake: `x test unit --filter <file> -- --rerun-each=30 --randomize` — the `--` hands the rest to `bun test`.
|
|
1308
|
+
- Every framework package ships at least 2 tests that would catch a real regression. No `expect(true).toBe(true)`.
|
|
1309
|
+
- Tests live next to their source as `<file>.test.ts`.
|
|
1310
|
+
- A denial test per policy branch, not one happy-path test per action.
|
|
1311
|
+
- Every island declares its states, and the states worth declaring are the ones you cannot click to. What the pictures are then judged against: [Interface rules](Interface-Rules).
|
|
1312
|
+
- Assert on error **codes**, never on error message text.
|
|
1313
|
+
- CI runs `x verify` and nothing else.
|