@mandujs/core 0.53.3 → 0.54.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +654 -654
- package/package.json +1 -1
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
|
@@ -1,986 +1,986 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase C.1 — Semantic Primitives (@mandujs/core/testing barrel).
|
|
3
|
-
*
|
|
4
|
-
* Mandu-specific assertion primitives that the generic Playwright / bun:test
|
|
5
|
-
* vocabulary cannot express cleanly:
|
|
6
|
-
*
|
|
7
|
-
* - `expectContract` — Zod shape validation with strict/loose/drift
|
|
8
|
-
* modes and path-based `ignorePaths`. Replaces `JSON.stringify`
|
|
9
|
-
* comparisons.
|
|
10
|
-
* - `expectNavigation` — redirect-chain capture for Playwright pages.
|
|
11
|
-
* - `waitForIsland` — polls `data-island="<name>"`'s
|
|
12
|
-
* `data-hydrated`/`data-island-state` attribute. Short-circuits for
|
|
13
|
-
* `hydration:none` strategies.
|
|
14
|
-
* - `assertStreamBoundary`— consumes a streaming SSR response and counts
|
|
15
|
-
* `<!--$-->` / `<!--/$-->` boundary markers. Validates shell chunk
|
|
16
|
-
* byte budgets and tail chunk content.
|
|
17
|
-
* - `expectSemantic` — agent-delegated oracle. Writes an entry to
|
|
18
|
-
* `.mandu/ate-oracle-queue.jsonl` for an agent to judge later. CI is
|
|
19
|
-
* never blocked (`MANDU_ATE_DETERMINISTIC_ONLY=1` skips queueing).
|
|
20
|
-
* `promoteVerdicts: true` option lets a previously-failed verdict
|
|
21
|
-
* become a deterministic fail on re-run.
|
|
22
|
-
*
|
|
23
|
-
* Spec: docs/ate/phase-c-spec.md §C.1.
|
|
24
|
-
*
|
|
25
|
-
* These primitives live in `@mandujs/core/testing` (not `@mandujs/ate`) so
|
|
26
|
-
* they are usable in any Mandu project test file, with or without the ATE
|
|
27
|
-
* MCP server.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import {
|
|
31
|
-
existsSync,
|
|
32
|
-
mkdirSync,
|
|
33
|
-
readFileSync,
|
|
34
|
-
writeFileSync,
|
|
35
|
-
appendFileSync,
|
|
36
|
-
} from "node:fs";
|
|
37
|
-
import { join, dirname, isAbsolute } from "node:path";
|
|
38
|
-
|
|
39
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
40
|
-
// expectContract
|
|
41
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Minimal structural type of a Zod schema. We don't import `zod` here —
|
|
45
|
-
* the caller provides the schema and we only call `safeParse`. This keeps
|
|
46
|
-
* `@mandujs/core/testing` free of a zod peer dep for consumers who don't
|
|
47
|
-
* use it.
|
|
48
|
-
*/
|
|
49
|
-
/**
|
|
50
|
-
* Structural type matching both real `zod` schemas and hand-rolled
|
|
51
|
-
* fakes. We intentionally widen the `error.issues` shape to `unknown[]`
|
|
52
|
-
* — the primitive normalizes each issue internally so the call sites
|
|
53
|
-
* don't have to worry about Zod version drift.
|
|
54
|
-
*/
|
|
55
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
-
export interface ZodLikeSchema<T = unknown> {
|
|
57
|
-
safeParse: (input: unknown) => {
|
|
58
|
-
success: boolean;
|
|
59
|
-
data?: T;
|
|
60
|
-
// Intentionally loose — see note above.
|
|
61
|
-
error?: { issues: readonly unknown[] } | undefined;
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
interface NormalizedIssue {
|
|
66
|
-
path: Array<string | number>;
|
|
67
|
-
message: string;
|
|
68
|
-
code?: string;
|
|
69
|
-
expected?: string;
|
|
70
|
-
received?: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function normalizeIssue(raw: unknown): NormalizedIssue {
|
|
74
|
-
const obj = (raw ?? {}) as Record<string, unknown>;
|
|
75
|
-
const pathRaw = obj.path;
|
|
76
|
-
const path: Array<string | number> = Array.isArray(pathRaw)
|
|
77
|
-
? (pathRaw.filter((p) => typeof p === "string" || typeof p === "number") as Array<
|
|
78
|
-
string | number
|
|
79
|
-
>)
|
|
80
|
-
: [];
|
|
81
|
-
return {
|
|
82
|
-
path,
|
|
83
|
-
message: typeof obj.message === "string" ? obj.message : "",
|
|
84
|
-
...(typeof obj.code === "string" ? { code: obj.code } : {}),
|
|
85
|
-
...(typeof obj.expected === "string" ? { expected: obj.expected } : {}),
|
|
86
|
-
...(typeof obj.received === "string" ? { received: obj.received } : {}),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export type ContractMode = "strict" | "loose" | "drift-tolerant";
|
|
91
|
-
|
|
92
|
-
export interface ContractViolation {
|
|
93
|
-
path: string;
|
|
94
|
-
expected: string;
|
|
95
|
-
actual: string;
|
|
96
|
-
severity: "critical" | "warning";
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export interface ExpectContractOptions {
|
|
100
|
-
mode?: ContractMode;
|
|
101
|
-
/**
|
|
102
|
-
* Dot-notation paths to ignore. Use `.createdAt`, `.items[0].updatedAt`,
|
|
103
|
-
* or `.user.id`. Matching is prefix-aware — `.user` ignores every
|
|
104
|
-
* descendant field.
|
|
105
|
-
*/
|
|
106
|
-
ignorePaths?: string[];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export interface ExpectContractResult {
|
|
110
|
-
status: "pass" | "fail";
|
|
111
|
-
violations: ContractViolation[];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Validate `actual` against a Zod schema and surface structured
|
|
116
|
-
* violations. Throws when `status === "fail"` unless `mode` is
|
|
117
|
-
* `drift-tolerant`, which only records warnings.
|
|
118
|
-
*
|
|
119
|
-
* @example
|
|
120
|
-
* ```ts
|
|
121
|
-
* expectContract(await res.json(), SignupResponseSchema, {
|
|
122
|
-
* mode: "loose",
|
|
123
|
-
* ignorePaths: [".createdAt", ".user.id"],
|
|
124
|
-
* });
|
|
125
|
-
* ```
|
|
126
|
-
*/
|
|
127
|
-
export function expectContract<T>(
|
|
128
|
-
actual: unknown,
|
|
129
|
-
schema: ZodLikeSchema<T>,
|
|
130
|
-
options: ExpectContractOptions = {},
|
|
131
|
-
): ExpectContractResult {
|
|
132
|
-
const mode: ContractMode = options.mode ?? "strict";
|
|
133
|
-
const ignore = options.ignorePaths ?? [];
|
|
134
|
-
|
|
135
|
-
const result = schema.safeParse(actual);
|
|
136
|
-
|
|
137
|
-
if (result.success) {
|
|
138
|
-
// Strict mode: walk `actual` looking for keys not mentioned anywhere
|
|
139
|
-
// in the schema. We can't introspect a Zod schema structurally without
|
|
140
|
-
// the runtime — instead we round-trip through `safeParse`'s `data`
|
|
141
|
-
// which a default Zod schema strips unknown keys from (unless the
|
|
142
|
-
// schema uses `.passthrough()` / `.strict()`). If `data` shape differs
|
|
143
|
-
// from `actual` in strict mode, we flag the extras.
|
|
144
|
-
if (mode === "strict" && result.data !== undefined) {
|
|
145
|
-
const extras = findExtraKeys("", actual, result.data, ignore);
|
|
146
|
-
if (extras.length > 0) {
|
|
147
|
-
const violations: ContractViolation[] = extras.map((e) => ({
|
|
148
|
-
path: e,
|
|
149
|
-
expected: "(not in schema)",
|
|
150
|
-
actual: "extra key present",
|
|
151
|
-
severity: "critical",
|
|
152
|
-
}));
|
|
153
|
-
throwViolations(violations, "strict");
|
|
154
|
-
return { status: "fail", violations };
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
return { status: "pass", violations: [] };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// safeParse failed — translate Zod issues to our violation shape.
|
|
161
|
-
const rawIssues = result.error?.issues ?? [];
|
|
162
|
-
const violations: ContractViolation[] = [];
|
|
163
|
-
for (const raw of rawIssues) {
|
|
164
|
-
const issue = normalizeIssue(raw);
|
|
165
|
-
const path = pathToString(issue.path);
|
|
166
|
-
if (isIgnored(path, ignore)) continue;
|
|
167
|
-
|
|
168
|
-
// loose mode forgives extra-key errors (unrecognized keys) but keeps
|
|
169
|
-
// `missing_required` + format violations.
|
|
170
|
-
if (mode === "loose" && isExtraKeyIssue(issue)) continue;
|
|
171
|
-
|
|
172
|
-
const severity: "critical" | "warning" =
|
|
173
|
-
mode === "drift-tolerant" ? "warning" : "critical";
|
|
174
|
-
violations.push({
|
|
175
|
-
path,
|
|
176
|
-
expected: issue.expected ?? issue.message,
|
|
177
|
-
actual: issue.received ?? describeActual(actual, issue.path),
|
|
178
|
-
severity,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (violations.length === 0) {
|
|
183
|
-
return { status: "pass", violations: [] };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (mode === "drift-tolerant") {
|
|
187
|
-
// Warnings collected; no throw. Caller may log via
|
|
188
|
-
// `mandu_ate_remember({ kind: "contract_drift" })`.
|
|
189
|
-
return { status: "fail", violations };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
throwViolations(violations, mode);
|
|
193
|
-
return { status: "fail", violations };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function isExtraKeyIssue(issue: { code?: string; message?: string }): boolean {
|
|
197
|
-
if (issue.code === "unrecognized_keys") return true;
|
|
198
|
-
return /unrecognized key/i.test(issue.message ?? "");
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function throwViolations(violations: ContractViolation[], mode: ContractMode): never {
|
|
202
|
-
const summary = violations
|
|
203
|
-
.slice(0, 5)
|
|
204
|
-
.map((v) => ` - ${v.path}: expected ${v.expected}, got ${v.actual}`)
|
|
205
|
-
.join("\n");
|
|
206
|
-
const more = violations.length > 5 ? `\n ... +${violations.length - 5} more` : "";
|
|
207
|
-
throw new ContractAssertionError(
|
|
208
|
-
`expectContract (${mode}) failed with ${violations.length} violation(s):\n${summary}${more}`,
|
|
209
|
-
violations,
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export class ContractAssertionError extends Error {
|
|
214
|
-
violations: ContractViolation[];
|
|
215
|
-
constructor(message: string, violations: ContractViolation[]) {
|
|
216
|
-
super(message);
|
|
217
|
-
this.name = "ContractAssertionError";
|
|
218
|
-
this.violations = violations;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function pathToString(path: Array<string | number>): string {
|
|
223
|
-
let out = "";
|
|
224
|
-
for (const seg of path) {
|
|
225
|
-
if (typeof seg === "number") out += `[${seg}]`;
|
|
226
|
-
else out += `.${seg}`;
|
|
227
|
-
}
|
|
228
|
-
return out;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function isIgnored(path: string, ignore: string[]): boolean {
|
|
232
|
-
for (const ign of ignore) {
|
|
233
|
-
if (path === ign || path.startsWith(ign + ".") || path.startsWith(ign + "[")) {
|
|
234
|
-
return true;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
return false;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function describeActual(actual: unknown, path: Array<string | number>): string {
|
|
241
|
-
let cursor: unknown = actual;
|
|
242
|
-
for (const seg of path) {
|
|
243
|
-
if (cursor === null || cursor === undefined) return "undefined";
|
|
244
|
-
cursor = (cursor as Record<string | number, unknown>)[seg as string];
|
|
245
|
-
}
|
|
246
|
-
if (cursor === null) return "null";
|
|
247
|
-
if (cursor === undefined) return "undefined";
|
|
248
|
-
if (typeof cursor === "object") return JSON.stringify(cursor).slice(0, 60);
|
|
249
|
-
return JSON.stringify(cursor);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Find keys present in `actual` that are NOT present in `parsed` — extra
|
|
254
|
-
* keys Zod stripped during safeParse. Only used in strict mode.
|
|
255
|
-
*/
|
|
256
|
-
function findExtraKeys(
|
|
257
|
-
prefix: string,
|
|
258
|
-
actual: unknown,
|
|
259
|
-
parsed: unknown,
|
|
260
|
-
ignore: string[],
|
|
261
|
-
): string[] {
|
|
262
|
-
const out: string[] = [];
|
|
263
|
-
if (!actual || typeof actual !== "object" || Array.isArray(actual)) return out;
|
|
264
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return out;
|
|
265
|
-
|
|
266
|
-
const actualKeys = Object.keys(actual as Record<string, unknown>);
|
|
267
|
-
const parsedKeys = new Set(Object.keys(parsed as Record<string, unknown>));
|
|
268
|
-
|
|
269
|
-
for (const key of actualKeys) {
|
|
270
|
-
const path = `${prefix}.${key}`;
|
|
271
|
-
if (isIgnored(path, ignore)) continue;
|
|
272
|
-
if (!parsedKeys.has(key)) {
|
|
273
|
-
out.push(path);
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
const aVal = (actual as Record<string, unknown>)[key];
|
|
277
|
-
const pVal = (parsed as Record<string, unknown>)[key];
|
|
278
|
-
if (
|
|
279
|
-
aVal !== null &&
|
|
280
|
-
typeof aVal === "object" &&
|
|
281
|
-
!Array.isArray(aVal) &&
|
|
282
|
-
pVal !== null &&
|
|
283
|
-
typeof pVal === "object" &&
|
|
284
|
-
!Array.isArray(pVal)
|
|
285
|
-
) {
|
|
286
|
-
out.push(...findExtraKeys(path, aVal, pVal, ignore));
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return out;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
294
|
-
// expectNavigation
|
|
295
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Minimal Playwright Page shape — we only touch the bits we need so
|
|
299
|
-
* consumers can use `@playwright/test`'s `Page` without creating an
|
|
300
|
-
* import coupling.
|
|
301
|
-
*/
|
|
302
|
-
export interface PlaywrightLikePage {
|
|
303
|
-
url(): string;
|
|
304
|
-
on(event: "framenavigated", listener: (frame: { url: () => string }) => void): void;
|
|
305
|
-
off?(event: "framenavigated", listener: (frame: { url: () => string }) => void): void;
|
|
306
|
-
waitForURL?(url: string | RegExp, opts?: { timeout?: number }): Promise<void>;
|
|
307
|
-
waitForLoadState?(state?: "load" | "networkidle" | "domcontentloaded", opts?: { timeout?: number }): Promise<void>;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export interface ExpectNavigationInput {
|
|
311
|
-
from?: string;
|
|
312
|
-
to: string | RegExp;
|
|
313
|
-
/** Exact chain length — overrides `maxRedirects` when both set. */
|
|
314
|
-
redirectCount?: number;
|
|
315
|
-
/** ≤ — chain length must not exceed this. */
|
|
316
|
-
maxRedirects?: number;
|
|
317
|
-
/** Milliseconds to wait for the terminal URL to match. Default 5000. */
|
|
318
|
-
timeoutMs?: number;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export interface ExpectNavigationResult {
|
|
322
|
-
status: "pass";
|
|
323
|
-
chain: string[];
|
|
324
|
-
finalUrl: string;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Validate a redirect chain. Installs a `framenavigated` listener before
|
|
329
|
-
* asserting the final URL matches `to`. When `from` is given we assert
|
|
330
|
-
* the starting URL matches first.
|
|
331
|
-
*
|
|
332
|
-
* Emits a structured `failure.v1` `redirect_unexpected` error on
|
|
333
|
-
* mismatch — callers should wrap in try/catch to translate to their
|
|
334
|
-
* runner's fail helper.
|
|
335
|
-
*/
|
|
336
|
-
export async function expectNavigation(
|
|
337
|
-
page: PlaywrightLikePage,
|
|
338
|
-
expectation: ExpectNavigationInput,
|
|
339
|
-
): Promise<ExpectNavigationResult> {
|
|
340
|
-
const timeoutMs = expectation.timeoutMs ?? 5000;
|
|
341
|
-
const chain: string[] = [];
|
|
342
|
-
let firstUrl: string | null = null;
|
|
343
|
-
|
|
344
|
-
const onNav = (frame: { url: () => string }) => {
|
|
345
|
-
const u = frame.url();
|
|
346
|
-
if (firstUrl === null) firstUrl = u;
|
|
347
|
-
// Only append when the URL actually changes.
|
|
348
|
-
if (chain.length === 0 || chain[chain.length - 1] !== u) {
|
|
349
|
-
chain.push(u);
|
|
350
|
-
}
|
|
351
|
-
};
|
|
352
|
-
page.on("framenavigated", onNav);
|
|
353
|
-
|
|
354
|
-
try {
|
|
355
|
-
// Seed the chain with the current URL before waiting, so synchronous
|
|
356
|
-
// navigations are captured even if `framenavigated` fires before we
|
|
357
|
-
// attach.
|
|
358
|
-
const current = page.url();
|
|
359
|
-
if (current) chain.push(current);
|
|
360
|
-
|
|
361
|
-
if (page.waitForURL) {
|
|
362
|
-
try {
|
|
363
|
-
await page.waitForURL(expectation.to, { timeout: timeoutMs });
|
|
364
|
-
} catch {
|
|
365
|
-
// fall through — we'll diagnose below
|
|
366
|
-
}
|
|
367
|
-
} else {
|
|
368
|
-
// Fallback poll when waitForURL isn't available (mock pages etc).
|
|
369
|
-
const deadline = Date.now() + timeoutMs;
|
|
370
|
-
while (Date.now() < deadline) {
|
|
371
|
-
if (urlMatches(page.url(), expectation.to)) break;
|
|
372
|
-
await new Promise((r) => setTimeout(r, 25));
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const finalUrl = page.url();
|
|
377
|
-
if (chain.length === 0 || chain[chain.length - 1] !== finalUrl) {
|
|
378
|
-
chain.push(finalUrl);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Validate `from`.
|
|
382
|
-
if (expectation.from !== undefined) {
|
|
383
|
-
const first = chain[0];
|
|
384
|
-
if (first && !urlMatches(first, expectation.from)) {
|
|
385
|
-
throw new NavigationAssertionError(
|
|
386
|
-
`expectNavigation: starting URL mismatch. Expected ${expectation.from}, got ${first}`,
|
|
387
|
-
{ from: expectation.from, expectedTo: String(expectation.to), actualTo: finalUrl, chain },
|
|
388
|
-
);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// Validate final URL.
|
|
393
|
-
if (!urlMatches(finalUrl, expectation.to)) {
|
|
394
|
-
throw new NavigationAssertionError(
|
|
395
|
-
`expectNavigation: terminal URL mismatch. Expected ${expectation.to}, got ${finalUrl}`,
|
|
396
|
-
{ from: expectation.from ?? "", expectedTo: String(expectation.to), actualTo: finalUrl, chain },
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Validate chain length.
|
|
401
|
-
// Redirect count excludes the starting URL itself when `from` is set.
|
|
402
|
-
const chainHops = expectation.from !== undefined ? Math.max(0, chain.length - 1) : chain.length;
|
|
403
|
-
if (expectation.redirectCount !== undefined && chainHops !== expectation.redirectCount) {
|
|
404
|
-
throw new NavigationAssertionError(
|
|
405
|
-
`expectNavigation: redirect count mismatch. Expected exactly ${expectation.redirectCount}, got ${chainHops}`,
|
|
406
|
-
{ from: expectation.from ?? "", expectedTo: String(expectation.to), actualTo: finalUrl, chain },
|
|
407
|
-
);
|
|
408
|
-
}
|
|
409
|
-
if (expectation.maxRedirects !== undefined && chainHops > expectation.maxRedirects) {
|
|
410
|
-
throw new NavigationAssertionError(
|
|
411
|
-
`expectNavigation: redirect chain too long. Expected ≤ ${expectation.maxRedirects}, got ${chainHops}`,
|
|
412
|
-
{ from: expectation.from ?? "", expectedTo: String(expectation.to), actualTo: finalUrl, chain },
|
|
413
|
-
);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
return { status: "pass", chain, finalUrl };
|
|
417
|
-
} finally {
|
|
418
|
-
if (page.off) {
|
|
419
|
-
try {
|
|
420
|
-
page.off("framenavigated", onNav);
|
|
421
|
-
} catch {
|
|
422
|
-
// ignore
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
export class NavigationAssertionError extends Error {
|
|
429
|
-
/** Shape matches `failure.v1` `redirect_unexpected` detail. */
|
|
430
|
-
detail: {
|
|
431
|
-
from: string;
|
|
432
|
-
expectedTo: string;
|
|
433
|
-
actualTo: string;
|
|
434
|
-
chain: string[];
|
|
435
|
-
};
|
|
436
|
-
kind = "redirect_unexpected" as const;
|
|
437
|
-
constructor(
|
|
438
|
-
message: string,
|
|
439
|
-
detail: { from: string; expectedTo: string; actualTo: string; chain: string[] },
|
|
440
|
-
) {
|
|
441
|
-
super(message);
|
|
442
|
-
this.name = "NavigationAssertionError";
|
|
443
|
-
this.detail = detail;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function urlMatches(actual: string, expected: string | RegExp): boolean {
|
|
448
|
-
if (expected instanceof RegExp) return expected.test(actual);
|
|
449
|
-
// String match — treat as substring OR exact. "/kr" should match
|
|
450
|
-
// "http://localhost/kr" too.
|
|
451
|
-
if (actual === expected) return true;
|
|
452
|
-
try {
|
|
453
|
-
const parsed = new URL(actual);
|
|
454
|
-
if (parsed.pathname === expected) return true;
|
|
455
|
-
if (parsed.pathname + parsed.search === expected) return true;
|
|
456
|
-
} catch {
|
|
457
|
-
// fall through
|
|
458
|
-
}
|
|
459
|
-
return actual.includes(expected);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
463
|
-
// waitForIsland
|
|
464
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
465
|
-
|
|
466
|
-
export interface WaitForIslandOptions {
|
|
467
|
-
timeoutMs?: number;
|
|
468
|
-
/** "hydrated" (default) or "visible". "visible" only checks mount. */
|
|
469
|
-
state?: "hydrated" | "visible";
|
|
470
|
-
/**
|
|
471
|
-
* Override how islands expose their strategy. When this returns
|
|
472
|
-
* "none" we short-circuit and resolve immediately.
|
|
473
|
-
*/
|
|
474
|
-
strategyOf?: (page: PlaywrightIslandPage, name: string) => Promise<"none" | "other" | null>;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
export interface PlaywrightIslandPage {
|
|
478
|
-
/**
|
|
479
|
-
* Invoke a JS function in the browser context. We use
|
|
480
|
-
* `page.evaluate(fn, arg)` — same signature as Playwright's `Page`.
|
|
481
|
-
*/
|
|
482
|
-
evaluate<T, A>(fn: (arg: A) => T, arg: A): Promise<T>;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Wait until `[data-island="<name>"]` is hydrated.
|
|
487
|
-
*
|
|
488
|
-
* `hydration:none` strategy islands resolve immediately (Mandu's SSR
|
|
489
|
-
* emits `data-island-strategy="none"` for those — we check that first
|
|
490
|
-
* and short-circuit).
|
|
491
|
-
*
|
|
492
|
-
* Primary signal: `data-hydrated="true"` attribute (the one emitted by
|
|
493
|
-
* `@mandujs/core/client/hydrate`). Fallback: `data-island-state="hydrated"`.
|
|
494
|
-
*/
|
|
495
|
-
export async function waitForIsland(
|
|
496
|
-
page: PlaywrightIslandPage,
|
|
497
|
-
name: string,
|
|
498
|
-
options: WaitForIslandOptions = {},
|
|
499
|
-
): Promise<void> {
|
|
500
|
-
const timeoutMs = options.timeoutMs ?? 3000;
|
|
501
|
-
const state = options.state ?? "hydrated";
|
|
502
|
-
|
|
503
|
-
// Short-circuit for hydration:none islands.
|
|
504
|
-
try {
|
|
505
|
-
const strategy = await page.evaluate(
|
|
506
|
-
(n: string) => {
|
|
507
|
-
const el = document.querySelector(`[data-island="${n}"]`);
|
|
508
|
-
if (!el) return null;
|
|
509
|
-
const s = el.getAttribute("data-island-strategy");
|
|
510
|
-
return s ?? null;
|
|
511
|
-
},
|
|
512
|
-
name,
|
|
513
|
-
);
|
|
514
|
-
if (strategy === "none") {
|
|
515
|
-
return;
|
|
516
|
-
}
|
|
517
|
-
} catch {
|
|
518
|
-
// fall through — evaluate may be unsupported in mocks
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
const deadline = Date.now() + timeoutMs;
|
|
522
|
-
const pollInterval = 25;
|
|
523
|
-
|
|
524
|
-
while (Date.now() < deadline) {
|
|
525
|
-
try {
|
|
526
|
-
const status = await page.evaluate(
|
|
527
|
-
(args: { name: string; state: string }) => {
|
|
528
|
-
const el = document.querySelector(`[data-island="${args.name}"]`);
|
|
529
|
-
if (!el) return { mounted: false, hydrated: false };
|
|
530
|
-
const mounted = true;
|
|
531
|
-
if (args.state === "visible") return { mounted, hydrated: mounted };
|
|
532
|
-
// primary
|
|
533
|
-
if (el.getAttribute("data-hydrated") === "true") {
|
|
534
|
-
return { mounted, hydrated: true };
|
|
535
|
-
}
|
|
536
|
-
// fallback
|
|
537
|
-
if (el.getAttribute("data-island-state") === "hydrated") {
|
|
538
|
-
return { mounted, hydrated: true };
|
|
539
|
-
}
|
|
540
|
-
return { mounted, hydrated: false };
|
|
541
|
-
},
|
|
542
|
-
{ name, state },
|
|
543
|
-
);
|
|
544
|
-
if (state === "visible" && status.mounted) return;
|
|
545
|
-
if (status.hydrated) return;
|
|
546
|
-
} catch {
|
|
547
|
-
// evaluate failure — treat as not-yet-ready and retry.
|
|
548
|
-
}
|
|
549
|
-
await new Promise((r) => setTimeout(r, pollInterval));
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
throw new HydrationTimeoutError(
|
|
553
|
-
`waitForIsland: island "${name}" did not reach state=${state} within ${timeoutMs}ms`,
|
|
554
|
-
{ island: name, waitedMs: timeoutMs },
|
|
555
|
-
);
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
export class HydrationTimeoutError extends Error {
|
|
559
|
-
kind = "hydration_timeout" as const;
|
|
560
|
-
detail: { island: string; waitedMs: number };
|
|
561
|
-
constructor(message: string, detail: { island: string; waitedMs: number }) {
|
|
562
|
-
super(message);
|
|
563
|
-
this.name = "HydrationTimeoutError";
|
|
564
|
-
this.detail = detail;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
569
|
-
// assertStreamBoundary
|
|
570
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
571
|
-
|
|
572
|
-
export interface AssertStreamBoundaryInput {
|
|
573
|
-
/** Substrings the FIRST decoded chunk must contain (all of them). */
|
|
574
|
-
shellChunkContains?: string[];
|
|
575
|
-
/** Exact count of `<!--$-->` / `<!--/$-->` pairs. */
|
|
576
|
-
boundaryCount?: number;
|
|
577
|
-
/** First-chunk size guard (bytes). */
|
|
578
|
-
firstChunkMaxSizeBytes?: number;
|
|
579
|
-
/** Any one of these strings must appear in the final chunk. */
|
|
580
|
-
tailChunkContainsAnyOf?: string[];
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
export interface AssertStreamBoundaryResult {
|
|
584
|
-
status: "pass";
|
|
585
|
-
chunks: number;
|
|
586
|
-
totalBytes: number;
|
|
587
|
-
boundaryOpenCount: number;
|
|
588
|
-
boundaryCloseCount: number;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* Consume a streaming Response chunk-by-chunk and validate boundary
|
|
593
|
-
* markers / shell content / byte budgets.
|
|
594
|
-
*
|
|
595
|
-
* Throws `StreamBoundaryError` (failure.v1-shaped) on mismatch.
|
|
596
|
-
*/
|
|
597
|
-
export async function assertStreamBoundary(
|
|
598
|
-
response: Response,
|
|
599
|
-
expectations: AssertStreamBoundaryInput,
|
|
600
|
-
): Promise<AssertStreamBoundaryResult> {
|
|
601
|
-
const body = response.body;
|
|
602
|
-
if (!body) {
|
|
603
|
-
throw new StreamBoundaryError("assertStreamBoundary: response has no body", {
|
|
604
|
-
reason: "no_body",
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
const reader = body.getReader();
|
|
609
|
-
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
610
|
-
const chunks: string[] = [];
|
|
611
|
-
let totalBytes = 0;
|
|
612
|
-
let firstChunkBytes = 0;
|
|
613
|
-
|
|
614
|
-
try {
|
|
615
|
-
// eslint-disable-next-line no-constant-condition
|
|
616
|
-
while (true) {
|
|
617
|
-
const { done, value } = await reader.read();
|
|
618
|
-
if (done) break;
|
|
619
|
-
if (!value) continue;
|
|
620
|
-
const bytes = value.byteLength;
|
|
621
|
-
totalBytes += bytes;
|
|
622
|
-
if (chunks.length === 0) firstChunkBytes = bytes;
|
|
623
|
-
chunks.push(decoder.decode(value, { stream: true }));
|
|
624
|
-
}
|
|
625
|
-
// flush trailing decoder state
|
|
626
|
-
chunks.push(decoder.decode());
|
|
627
|
-
} finally {
|
|
628
|
-
try {
|
|
629
|
-
reader.releaseLock();
|
|
630
|
-
} catch {
|
|
631
|
-
// ignore
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
const shell = chunks[0] ?? "";
|
|
636
|
-
// Walk back to find the last non-empty chunk. The TextDecoder's final
|
|
637
|
-
// flush can append an empty string — that would give us a useless tail.
|
|
638
|
-
let tail = "";
|
|
639
|
-
for (let i = chunks.length - 1; i >= 0; i--) {
|
|
640
|
-
if (chunks[i] && chunks[i].length > 0) {
|
|
641
|
-
tail = chunks[i];
|
|
642
|
-
break;
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
if (!tail) tail = chunks.join("");
|
|
646
|
-
const full = chunks.join("");
|
|
647
|
-
|
|
648
|
-
// shellChunkContains
|
|
649
|
-
if (expectations.shellChunkContains) {
|
|
650
|
-
for (const needle of expectations.shellChunkContains) {
|
|
651
|
-
if (!shell.includes(needle)) {
|
|
652
|
-
throw new StreamBoundaryError(
|
|
653
|
-
`assertStreamBoundary: first chunk missing expected content "${needle}"`,
|
|
654
|
-
{
|
|
655
|
-
reason: "shell_missing_content",
|
|
656
|
-
missing: needle,
|
|
657
|
-
shellPreview: shell.slice(0, 200),
|
|
658
|
-
},
|
|
659
|
-
);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// firstChunkMaxSizeBytes
|
|
665
|
-
if (
|
|
666
|
-
expectations.firstChunkMaxSizeBytes !== undefined &&
|
|
667
|
-
firstChunkBytes > expectations.firstChunkMaxSizeBytes
|
|
668
|
-
) {
|
|
669
|
-
throw new StreamBoundaryError(
|
|
670
|
-
`assertStreamBoundary: first chunk ${firstChunkBytes} bytes exceeds budget ${expectations.firstChunkMaxSizeBytes}`,
|
|
671
|
-
{
|
|
672
|
-
reason: "shell_over_budget",
|
|
673
|
-
firstChunkBytes,
|
|
674
|
-
budget: expectations.firstChunkMaxSizeBytes,
|
|
675
|
-
},
|
|
676
|
-
);
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// boundary count — count occurrences of `<!--$-->` (open) and
|
|
680
|
-
// `<!--/$-->` (close). A "boundary" is one open+close pair, so we
|
|
681
|
-
// expect the open count to equal the close count AND equal
|
|
682
|
-
// `expectations.boundaryCount`.
|
|
683
|
-
const openCount = countOccurrences(full, "<!--$-->");
|
|
684
|
-
const closeCount = countOccurrences(full, "<!--/$-->");
|
|
685
|
-
if (
|
|
686
|
-
expectations.boundaryCount !== undefined &&
|
|
687
|
-
openCount !== expectations.boundaryCount
|
|
688
|
-
) {
|
|
689
|
-
throw new StreamBoundaryError(
|
|
690
|
-
`assertStreamBoundary: expected ${expectations.boundaryCount} Suspense boundaries, saw ${openCount} open / ${closeCount} close`,
|
|
691
|
-
{ reason: "boundary_count_mismatch", expected: expectations.boundaryCount, openCount, closeCount },
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// tailChunkContainsAnyOf
|
|
696
|
-
if (expectations.tailChunkContainsAnyOf && expectations.tailChunkContainsAnyOf.length > 0) {
|
|
697
|
-
const hit = expectations.tailChunkContainsAnyOf.some((n) => tail.includes(n));
|
|
698
|
-
if (!hit) {
|
|
699
|
-
throw new StreamBoundaryError(
|
|
700
|
-
`assertStreamBoundary: tail chunk missing any of [${expectations.tailChunkContainsAnyOf.join(", ")}]`,
|
|
701
|
-
{
|
|
702
|
-
reason: "tail_missing_content",
|
|
703
|
-
candidates: expectations.tailChunkContainsAnyOf,
|
|
704
|
-
tailPreview: tail.slice(-200),
|
|
705
|
-
},
|
|
706
|
-
);
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
return {
|
|
711
|
-
status: "pass",
|
|
712
|
-
chunks: chunks.length,
|
|
713
|
-
totalBytes,
|
|
714
|
-
boundaryOpenCount: openCount,
|
|
715
|
-
boundaryCloseCount: closeCount,
|
|
716
|
-
};
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
export class StreamBoundaryError extends Error {
|
|
720
|
-
kind = "stream_boundary_mismatch" as const;
|
|
721
|
-
detail: Record<string, unknown>;
|
|
722
|
-
constructor(message: string, detail: Record<string, unknown>) {
|
|
723
|
-
super(message);
|
|
724
|
-
this.name = "StreamBoundaryError";
|
|
725
|
-
this.detail = detail;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
function countOccurrences(haystack: string, needle: string): number {
|
|
730
|
-
if (!needle) return 0;
|
|
731
|
-
let count = 0;
|
|
732
|
-
let idx = 0;
|
|
733
|
-
while ((idx = haystack.indexOf(needle, idx)) !== -1) {
|
|
734
|
-
count++;
|
|
735
|
-
idx += needle.length;
|
|
736
|
-
}
|
|
737
|
-
return count;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
741
|
-
// expectSemantic — agent-delegated oracle
|
|
742
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
743
|
-
|
|
744
|
-
export interface ExpectSemanticOptions {
|
|
745
|
-
/** What to capture. Default "both". */
|
|
746
|
-
capture?: "screenshot" | "dom" | "both";
|
|
747
|
-
/** Treat the queueing as non-blocking (default). */
|
|
748
|
-
deferToAgent?: boolean;
|
|
749
|
-
/**
|
|
750
|
-
* When true, past `failed` verdicts for the same claim promote to a
|
|
751
|
-
* deterministic fail on this run. Default false. Per §C.1.5.
|
|
752
|
-
*/
|
|
753
|
-
promoteVerdicts?: boolean;
|
|
754
|
-
/** Override the repo root — default `process.cwd()`. */
|
|
755
|
-
repoRoot?: string;
|
|
756
|
-
/** Override the spec path recorded in the queue entry. */
|
|
757
|
-
specPath?: string;
|
|
758
|
-
/** Override the runId recorded in the queue entry. */
|
|
759
|
-
runId?: string;
|
|
760
|
-
/** Inject a fixed timestamp — for goldens / tests. */
|
|
761
|
-
now?: () => string;
|
|
762
|
-
/**
|
|
763
|
-
* Optional DOM snapshot — tests pass it in explicitly so we don't have
|
|
764
|
-
* to stand up a headless browser. In real use this is captured via
|
|
765
|
-
* `page.content()`.
|
|
766
|
-
*/
|
|
767
|
-
domSnapshot?: string;
|
|
768
|
-
/**
|
|
769
|
-
* Optional screenshot bytes — tests pass it in explicitly so we don't
|
|
770
|
-
* require Playwright at callers.
|
|
771
|
-
*/
|
|
772
|
-
screenshotBytes?: Uint8Array;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
export interface ExpectSemanticPage {
|
|
776
|
-
/** Playwright Page.content(). */
|
|
777
|
-
content?(): Promise<string>;
|
|
778
|
-
/** Playwright Page.screenshot() — returns binary. */
|
|
779
|
-
screenshot?(opts?: Record<string, unknown>): Promise<Uint8Array>;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
export interface OracleQueueEntry {
|
|
783
|
-
assertionId: string;
|
|
784
|
-
specPath: string;
|
|
785
|
-
runId: string;
|
|
786
|
-
claim: string;
|
|
787
|
-
artifactPath: string;
|
|
788
|
-
status: "pending" | "passed" | "failed";
|
|
789
|
-
verdict?: {
|
|
790
|
-
judgedBy: "agent" | "human";
|
|
791
|
-
reason: string;
|
|
792
|
-
timestamp: string;
|
|
793
|
-
};
|
|
794
|
-
timestamp: string;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
export interface ExpectSemanticResult {
|
|
798
|
-
status: "pass" | "fail";
|
|
799
|
-
assertionId: string;
|
|
800
|
-
/** Set when a past verdict triggered a promoted deterministic fail. */
|
|
801
|
-
promotedFromVerdict?: OracleQueueEntry["verdict"];
|
|
802
|
-
/** True when we skipped queueing (CI / DETERMINISTIC_ONLY). */
|
|
803
|
-
deferred: boolean;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
/**
|
|
807
|
-
* Queue a semantic claim for agent judgment.
|
|
808
|
-
*
|
|
809
|
-
* Default behaviour is **non-blocking** — we enqueue to
|
|
810
|
-
* `.mandu/ate-oracle-queue.jsonl`, return `status: "pass"`, and let a
|
|
811
|
-
* later agent session judge via `mandu_ate_oracle_verdict`.
|
|
812
|
-
*
|
|
813
|
-
* Two escape hatches:
|
|
814
|
-
* - `MANDU_ATE_DETERMINISTIC_ONLY=1` → no file writes, return immediately.
|
|
815
|
-
* - `promoteVerdicts: true` + past `failed` verdict for the same claim
|
|
816
|
-
* → throw `SemanticDivergenceError` to regress the spec.
|
|
817
|
-
*/
|
|
818
|
-
export function expectSemantic(
|
|
819
|
-
page: ExpectSemanticPage,
|
|
820
|
-
claim: string,
|
|
821
|
-
options: ExpectSemanticOptions = {},
|
|
822
|
-
): ExpectSemanticResult {
|
|
823
|
-
const repoRoot = options.repoRoot ?? process.cwd();
|
|
824
|
-
const deterministicOnly = process.env.MANDU_ATE_DETERMINISTIC_ONLY === "1";
|
|
825
|
-
|
|
826
|
-
// Compute a stable assertionId — hash of (claim + specPath). This is
|
|
827
|
-
// how promoteVerdicts matches past verdicts without relying on random
|
|
828
|
-
// ids.
|
|
829
|
-
const specPath = options.specPath ?? inferSpecPath();
|
|
830
|
-
const runId = options.runId ?? deriveRunId();
|
|
831
|
-
const assertionId = stableAssertionId(claim, specPath);
|
|
832
|
-
|
|
833
|
-
// promoteVerdicts — scan existing queue for the same assertionId.
|
|
834
|
-
if (options.promoteVerdicts) {
|
|
835
|
-
const past = findPastVerdict(repoRoot, assertionId);
|
|
836
|
-
if (past && past.status === "failed" && past.verdict) {
|
|
837
|
-
throw new SemanticDivergenceError(
|
|
838
|
-
`expectSemantic: past verdict flagged this claim as failed. claim="${claim}" reason="${past.verdict.reason}"`,
|
|
839
|
-
{ claim, evidence: past.artifactPath, oraclePending: false },
|
|
840
|
-
);
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
if (deterministicOnly) {
|
|
845
|
-
return {
|
|
846
|
-
status: "pass",
|
|
847
|
-
assertionId,
|
|
848
|
-
deferred: true,
|
|
849
|
-
};
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
// Create artifact dir + write captures.
|
|
853
|
-
const nowStr = options.now ? options.now() : new Date().toISOString();
|
|
854
|
-
const artifactDir = join(repoRoot, ".mandu", "ate-oracle-queue", runId, assertionId);
|
|
855
|
-
try {
|
|
856
|
-
mkdirSync(artifactDir, { recursive: true });
|
|
857
|
-
} catch {
|
|
858
|
-
// fall through — queue is best-effort
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
const capture = options.capture ?? "both";
|
|
862
|
-
// Pull from explicit options first (test path), fall back to page calls.
|
|
863
|
-
const domPromise =
|
|
864
|
-
options.domSnapshot !== undefined
|
|
865
|
-
? Promise.resolve(options.domSnapshot)
|
|
866
|
-
: capture === "screenshot" || !page.content
|
|
867
|
-
? Promise.resolve(null)
|
|
868
|
-
: page.content().catch(() => null);
|
|
869
|
-
const screenshotPromise =
|
|
870
|
-
options.screenshotBytes !== undefined
|
|
871
|
-
? Promise.resolve(options.screenshotBytes)
|
|
872
|
-
: capture === "dom" || !page.screenshot
|
|
873
|
-
? Promise.resolve(null)
|
|
874
|
-
: page.screenshot().catch(() => null);
|
|
875
|
-
|
|
876
|
-
// Write captures synchronously after they resolve. expectSemantic is
|
|
877
|
-
// declared synchronous in §C.1.5 — but file writes here are also
|
|
878
|
-
// expected to not block on network, so we kick off a microtask and
|
|
879
|
-
// return. This matches the spec's "non-blocking for CI" contract.
|
|
880
|
-
void Promise.all([domPromise, screenshotPromise]).then(([dom, shot]) => {
|
|
881
|
-
try {
|
|
882
|
-
if (dom !== null && dom !== undefined) {
|
|
883
|
-
writeFileSync(join(artifactDir, "dom.html"), dom, "utf8");
|
|
884
|
-
}
|
|
885
|
-
if (shot) {
|
|
886
|
-
writeFileSync(join(artifactDir, "screenshot.png"), Buffer.from(shot));
|
|
887
|
-
}
|
|
888
|
-
} catch {
|
|
889
|
-
// swallow
|
|
890
|
-
}
|
|
891
|
-
});
|
|
892
|
-
|
|
893
|
-
// Append the queue entry immediately so agents see it before the
|
|
894
|
-
// artifacts finish writing.
|
|
895
|
-
const entry: OracleQueueEntry = {
|
|
896
|
-
assertionId,
|
|
897
|
-
specPath,
|
|
898
|
-
runId,
|
|
899
|
-
claim,
|
|
900
|
-
artifactPath: artifactDir.replace(/\\/g, "/"),
|
|
901
|
-
status: "pending",
|
|
902
|
-
timestamp: nowStr,
|
|
903
|
-
};
|
|
904
|
-
try {
|
|
905
|
-
const queuePath = join(repoRoot, ".mandu", "ate-oracle-queue.jsonl");
|
|
906
|
-
mkdirSync(dirname(queuePath), { recursive: true });
|
|
907
|
-
appendFileSync(queuePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
908
|
-
} catch {
|
|
909
|
-
// swallow — queue write is best-effort.
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
return {
|
|
913
|
-
status: "pass",
|
|
914
|
-
assertionId,
|
|
915
|
-
deferred: true,
|
|
916
|
-
};
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
export class SemanticDivergenceError extends Error {
|
|
920
|
-
kind = "semantic_divergence" as const;
|
|
921
|
-
detail: { claim: string; evidence?: string; oraclePending: boolean };
|
|
922
|
-
constructor(
|
|
923
|
-
message: string,
|
|
924
|
-
detail: { claim: string; evidence?: string; oraclePending: boolean },
|
|
925
|
-
) {
|
|
926
|
-
super(message);
|
|
927
|
-
this.name = "SemanticDivergenceError";
|
|
928
|
-
this.detail = detail;
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
function stableAssertionId(claim: string, specPath: string): string {
|
|
933
|
-
// Small deterministic hash — DJB2 variant. Don't need cryptographic
|
|
934
|
-
// strength; we just need stable ids across runs.
|
|
935
|
-
const input = `${specPath}|${claim}`;
|
|
936
|
-
let hash = 5381;
|
|
937
|
-
for (let i = 0; i < input.length; i++) {
|
|
938
|
-
hash = ((hash << 5) + hash + input.charCodeAt(i)) >>> 0;
|
|
939
|
-
}
|
|
940
|
-
return `sem-${hash.toString(16).padStart(8, "0")}`;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
function inferSpecPath(): string {
|
|
944
|
-
// Best-effort: peek at the stack for the first frame outside this file.
|
|
945
|
-
const err = new Error();
|
|
946
|
-
const stack = err.stack ?? "";
|
|
947
|
-
const lines = stack.split("\n");
|
|
948
|
-
for (const line of lines) {
|
|
949
|
-
if (line.includes("assertions.ts")) continue;
|
|
950
|
-
const match = line.match(/\(([^)]+?):(\d+):(\d+)\)/) || line.match(/at\s+([^\s]+?):(\d+):(\d+)/);
|
|
951
|
-
if (match) {
|
|
952
|
-
const file = match[1];
|
|
953
|
-
if (isAbsolute(file) && !file.includes("node_modules")) {
|
|
954
|
-
return file.replace(/\\/g, "/");
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
return "unknown.spec.ts";
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
function deriveRunId(): string {
|
|
962
|
-
return process.env.MANDU_ATE_RUN_ID ?? `run-${Date.now().toString(36)}`;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
function findPastVerdict(repoRoot: string, assertionId: string): OracleQueueEntry | null {
|
|
966
|
-
const path = join(repoRoot, ".mandu", "ate-oracle-queue.jsonl");
|
|
967
|
-
if (!existsSync(path)) return null;
|
|
968
|
-
try {
|
|
969
|
-
const content = readFileSync(path, "utf8");
|
|
970
|
-
// Walk from the tail — most recent verdict for this id wins.
|
|
971
|
-
const lines = content.split("\n").filter(Boolean);
|
|
972
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
973
|
-
try {
|
|
974
|
-
const e = JSON.parse(lines[i]) as OracleQueueEntry;
|
|
975
|
-
if (e.assertionId === assertionId && e.status !== "pending") {
|
|
976
|
-
return e;
|
|
977
|
-
}
|
|
978
|
-
} catch {
|
|
979
|
-
// skip corrupt line
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
} catch {
|
|
983
|
-
// swallow
|
|
984
|
-
}
|
|
985
|
-
return null;
|
|
986
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Phase C.1 — Semantic Primitives (@mandujs/core/testing barrel).
|
|
3
|
+
*
|
|
4
|
+
* Mandu-specific assertion primitives that the generic Playwright / bun:test
|
|
5
|
+
* vocabulary cannot express cleanly:
|
|
6
|
+
*
|
|
7
|
+
* - `expectContract` — Zod shape validation with strict/loose/drift
|
|
8
|
+
* modes and path-based `ignorePaths`. Replaces `JSON.stringify`
|
|
9
|
+
* comparisons.
|
|
10
|
+
* - `expectNavigation` — redirect-chain capture for Playwright pages.
|
|
11
|
+
* - `waitForIsland` — polls `data-island="<name>"`'s
|
|
12
|
+
* `data-hydrated`/`data-island-state` attribute. Short-circuits for
|
|
13
|
+
* `hydration:none` strategies.
|
|
14
|
+
* - `assertStreamBoundary`— consumes a streaming SSR response and counts
|
|
15
|
+
* `<!--$-->` / `<!--/$-->` boundary markers. Validates shell chunk
|
|
16
|
+
* byte budgets and tail chunk content.
|
|
17
|
+
* - `expectSemantic` — agent-delegated oracle. Writes an entry to
|
|
18
|
+
* `.mandu/ate-oracle-queue.jsonl` for an agent to judge later. CI is
|
|
19
|
+
* never blocked (`MANDU_ATE_DETERMINISTIC_ONLY=1` skips queueing).
|
|
20
|
+
* `promoteVerdicts: true` option lets a previously-failed verdict
|
|
21
|
+
* become a deterministic fail on re-run.
|
|
22
|
+
*
|
|
23
|
+
* Spec: docs/ate/phase-c-spec.md §C.1.
|
|
24
|
+
*
|
|
25
|
+
* These primitives live in `@mandujs/core/testing` (not `@mandujs/ate`) so
|
|
26
|
+
* they are usable in any Mandu project test file, with or without the ATE
|
|
27
|
+
* MCP server.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
existsSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
appendFileSync,
|
|
36
|
+
} from "node:fs";
|
|
37
|
+
import { join, dirname, isAbsolute } from "node:path";
|
|
38
|
+
|
|
39
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// expectContract
|
|
41
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Minimal structural type of a Zod schema. We don't import `zod` here —
|
|
45
|
+
* the caller provides the schema and we only call `safeParse`. This keeps
|
|
46
|
+
* `@mandujs/core/testing` free of a zod peer dep for consumers who don't
|
|
47
|
+
* use it.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Structural type matching both real `zod` schemas and hand-rolled
|
|
51
|
+
* fakes. We intentionally widen the `error.issues` shape to `unknown[]`
|
|
52
|
+
* — the primitive normalizes each issue internally so the call sites
|
|
53
|
+
* don't have to worry about Zod version drift.
|
|
54
|
+
*/
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
export interface ZodLikeSchema<T = unknown> {
|
|
57
|
+
safeParse: (input: unknown) => {
|
|
58
|
+
success: boolean;
|
|
59
|
+
data?: T;
|
|
60
|
+
// Intentionally loose — see note above.
|
|
61
|
+
error?: { issues: readonly unknown[] } | undefined;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface NormalizedIssue {
|
|
66
|
+
path: Array<string | number>;
|
|
67
|
+
message: string;
|
|
68
|
+
code?: string;
|
|
69
|
+
expected?: string;
|
|
70
|
+
received?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeIssue(raw: unknown): NormalizedIssue {
|
|
74
|
+
const obj = (raw ?? {}) as Record<string, unknown>;
|
|
75
|
+
const pathRaw = obj.path;
|
|
76
|
+
const path: Array<string | number> = Array.isArray(pathRaw)
|
|
77
|
+
? (pathRaw.filter((p) => typeof p === "string" || typeof p === "number") as Array<
|
|
78
|
+
string | number
|
|
79
|
+
>)
|
|
80
|
+
: [];
|
|
81
|
+
return {
|
|
82
|
+
path,
|
|
83
|
+
message: typeof obj.message === "string" ? obj.message : "",
|
|
84
|
+
...(typeof obj.code === "string" ? { code: obj.code } : {}),
|
|
85
|
+
...(typeof obj.expected === "string" ? { expected: obj.expected } : {}),
|
|
86
|
+
...(typeof obj.received === "string" ? { received: obj.received } : {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type ContractMode = "strict" | "loose" | "drift-tolerant";
|
|
91
|
+
|
|
92
|
+
export interface ContractViolation {
|
|
93
|
+
path: string;
|
|
94
|
+
expected: string;
|
|
95
|
+
actual: string;
|
|
96
|
+
severity: "critical" | "warning";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ExpectContractOptions {
|
|
100
|
+
mode?: ContractMode;
|
|
101
|
+
/**
|
|
102
|
+
* Dot-notation paths to ignore. Use `.createdAt`, `.items[0].updatedAt`,
|
|
103
|
+
* or `.user.id`. Matching is prefix-aware — `.user` ignores every
|
|
104
|
+
* descendant field.
|
|
105
|
+
*/
|
|
106
|
+
ignorePaths?: string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ExpectContractResult {
|
|
110
|
+
status: "pass" | "fail";
|
|
111
|
+
violations: ContractViolation[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validate `actual` against a Zod schema and surface structured
|
|
116
|
+
* violations. Throws when `status === "fail"` unless `mode` is
|
|
117
|
+
* `drift-tolerant`, which only records warnings.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* expectContract(await res.json(), SignupResponseSchema, {
|
|
122
|
+
* mode: "loose",
|
|
123
|
+
* ignorePaths: [".createdAt", ".user.id"],
|
|
124
|
+
* });
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export function expectContract<T>(
|
|
128
|
+
actual: unknown,
|
|
129
|
+
schema: ZodLikeSchema<T>,
|
|
130
|
+
options: ExpectContractOptions = {},
|
|
131
|
+
): ExpectContractResult {
|
|
132
|
+
const mode: ContractMode = options.mode ?? "strict";
|
|
133
|
+
const ignore = options.ignorePaths ?? [];
|
|
134
|
+
|
|
135
|
+
const result = schema.safeParse(actual);
|
|
136
|
+
|
|
137
|
+
if (result.success) {
|
|
138
|
+
// Strict mode: walk `actual` looking for keys not mentioned anywhere
|
|
139
|
+
// in the schema. We can't introspect a Zod schema structurally without
|
|
140
|
+
// the runtime — instead we round-trip through `safeParse`'s `data`
|
|
141
|
+
// which a default Zod schema strips unknown keys from (unless the
|
|
142
|
+
// schema uses `.passthrough()` / `.strict()`). If `data` shape differs
|
|
143
|
+
// from `actual` in strict mode, we flag the extras.
|
|
144
|
+
if (mode === "strict" && result.data !== undefined) {
|
|
145
|
+
const extras = findExtraKeys("", actual, result.data, ignore);
|
|
146
|
+
if (extras.length > 0) {
|
|
147
|
+
const violations: ContractViolation[] = extras.map((e) => ({
|
|
148
|
+
path: e,
|
|
149
|
+
expected: "(not in schema)",
|
|
150
|
+
actual: "extra key present",
|
|
151
|
+
severity: "critical",
|
|
152
|
+
}));
|
|
153
|
+
throwViolations(violations, "strict");
|
|
154
|
+
return { status: "fail", violations };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { status: "pass", violations: [] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// safeParse failed — translate Zod issues to our violation shape.
|
|
161
|
+
const rawIssues = result.error?.issues ?? [];
|
|
162
|
+
const violations: ContractViolation[] = [];
|
|
163
|
+
for (const raw of rawIssues) {
|
|
164
|
+
const issue = normalizeIssue(raw);
|
|
165
|
+
const path = pathToString(issue.path);
|
|
166
|
+
if (isIgnored(path, ignore)) continue;
|
|
167
|
+
|
|
168
|
+
// loose mode forgives extra-key errors (unrecognized keys) but keeps
|
|
169
|
+
// `missing_required` + format violations.
|
|
170
|
+
if (mode === "loose" && isExtraKeyIssue(issue)) continue;
|
|
171
|
+
|
|
172
|
+
const severity: "critical" | "warning" =
|
|
173
|
+
mode === "drift-tolerant" ? "warning" : "critical";
|
|
174
|
+
violations.push({
|
|
175
|
+
path,
|
|
176
|
+
expected: issue.expected ?? issue.message,
|
|
177
|
+
actual: issue.received ?? describeActual(actual, issue.path),
|
|
178
|
+
severity,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (violations.length === 0) {
|
|
183
|
+
return { status: "pass", violations: [] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (mode === "drift-tolerant") {
|
|
187
|
+
// Warnings collected; no throw. Caller may log via
|
|
188
|
+
// `mandu_ate_remember({ kind: "contract_drift" })`.
|
|
189
|
+
return { status: "fail", violations };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
throwViolations(violations, mode);
|
|
193
|
+
return { status: "fail", violations };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isExtraKeyIssue(issue: { code?: string; message?: string }): boolean {
|
|
197
|
+
if (issue.code === "unrecognized_keys") return true;
|
|
198
|
+
return /unrecognized key/i.test(issue.message ?? "");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function throwViolations(violations: ContractViolation[], mode: ContractMode): never {
|
|
202
|
+
const summary = violations
|
|
203
|
+
.slice(0, 5)
|
|
204
|
+
.map((v) => ` - ${v.path}: expected ${v.expected}, got ${v.actual}`)
|
|
205
|
+
.join("\n");
|
|
206
|
+
const more = violations.length > 5 ? `\n ... +${violations.length - 5} more` : "";
|
|
207
|
+
throw new ContractAssertionError(
|
|
208
|
+
`expectContract (${mode}) failed with ${violations.length} violation(s):\n${summary}${more}`,
|
|
209
|
+
violations,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export class ContractAssertionError extends Error {
|
|
214
|
+
violations: ContractViolation[];
|
|
215
|
+
constructor(message: string, violations: ContractViolation[]) {
|
|
216
|
+
super(message);
|
|
217
|
+
this.name = "ContractAssertionError";
|
|
218
|
+
this.violations = violations;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function pathToString(path: Array<string | number>): string {
|
|
223
|
+
let out = "";
|
|
224
|
+
for (const seg of path) {
|
|
225
|
+
if (typeof seg === "number") out += `[${seg}]`;
|
|
226
|
+
else out += `.${seg}`;
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isIgnored(path: string, ignore: string[]): boolean {
|
|
232
|
+
for (const ign of ignore) {
|
|
233
|
+
if (path === ign || path.startsWith(ign + ".") || path.startsWith(ign + "[")) {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function describeActual(actual: unknown, path: Array<string | number>): string {
|
|
241
|
+
let cursor: unknown = actual;
|
|
242
|
+
for (const seg of path) {
|
|
243
|
+
if (cursor === null || cursor === undefined) return "undefined";
|
|
244
|
+
cursor = (cursor as Record<string | number, unknown>)[seg as string];
|
|
245
|
+
}
|
|
246
|
+
if (cursor === null) return "null";
|
|
247
|
+
if (cursor === undefined) return "undefined";
|
|
248
|
+
if (typeof cursor === "object") return JSON.stringify(cursor).slice(0, 60);
|
|
249
|
+
return JSON.stringify(cursor);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Find keys present in `actual` that are NOT present in `parsed` — extra
|
|
254
|
+
* keys Zod stripped during safeParse. Only used in strict mode.
|
|
255
|
+
*/
|
|
256
|
+
function findExtraKeys(
|
|
257
|
+
prefix: string,
|
|
258
|
+
actual: unknown,
|
|
259
|
+
parsed: unknown,
|
|
260
|
+
ignore: string[],
|
|
261
|
+
): string[] {
|
|
262
|
+
const out: string[] = [];
|
|
263
|
+
if (!actual || typeof actual !== "object" || Array.isArray(actual)) return out;
|
|
264
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return out;
|
|
265
|
+
|
|
266
|
+
const actualKeys = Object.keys(actual as Record<string, unknown>);
|
|
267
|
+
const parsedKeys = new Set(Object.keys(parsed as Record<string, unknown>));
|
|
268
|
+
|
|
269
|
+
for (const key of actualKeys) {
|
|
270
|
+
const path = `${prefix}.${key}`;
|
|
271
|
+
if (isIgnored(path, ignore)) continue;
|
|
272
|
+
if (!parsedKeys.has(key)) {
|
|
273
|
+
out.push(path);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const aVal = (actual as Record<string, unknown>)[key];
|
|
277
|
+
const pVal = (parsed as Record<string, unknown>)[key];
|
|
278
|
+
if (
|
|
279
|
+
aVal !== null &&
|
|
280
|
+
typeof aVal === "object" &&
|
|
281
|
+
!Array.isArray(aVal) &&
|
|
282
|
+
pVal !== null &&
|
|
283
|
+
typeof pVal === "object" &&
|
|
284
|
+
!Array.isArray(pVal)
|
|
285
|
+
) {
|
|
286
|
+
out.push(...findExtraKeys(path, aVal, pVal, ignore));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
294
|
+
// expectNavigation
|
|
295
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Minimal Playwright Page shape — we only touch the bits we need so
|
|
299
|
+
* consumers can use `@playwright/test`'s `Page` without creating an
|
|
300
|
+
* import coupling.
|
|
301
|
+
*/
|
|
302
|
+
export interface PlaywrightLikePage {
|
|
303
|
+
url(): string;
|
|
304
|
+
on(event: "framenavigated", listener: (frame: { url: () => string }) => void): void;
|
|
305
|
+
off?(event: "framenavigated", listener: (frame: { url: () => string }) => void): void;
|
|
306
|
+
waitForURL?(url: string | RegExp, opts?: { timeout?: number }): Promise<void>;
|
|
307
|
+
waitForLoadState?(state?: "load" | "networkidle" | "domcontentloaded", opts?: { timeout?: number }): Promise<void>;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface ExpectNavigationInput {
|
|
311
|
+
from?: string;
|
|
312
|
+
to: string | RegExp;
|
|
313
|
+
/** Exact chain length — overrides `maxRedirects` when both set. */
|
|
314
|
+
redirectCount?: number;
|
|
315
|
+
/** ≤ — chain length must not exceed this. */
|
|
316
|
+
maxRedirects?: number;
|
|
317
|
+
/** Milliseconds to wait for the terminal URL to match. Default 5000. */
|
|
318
|
+
timeoutMs?: number;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface ExpectNavigationResult {
|
|
322
|
+
status: "pass";
|
|
323
|
+
chain: string[];
|
|
324
|
+
finalUrl: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Validate a redirect chain. Installs a `framenavigated` listener before
|
|
329
|
+
* asserting the final URL matches `to`. When `from` is given we assert
|
|
330
|
+
* the starting URL matches first.
|
|
331
|
+
*
|
|
332
|
+
* Emits a structured `failure.v1` `redirect_unexpected` error on
|
|
333
|
+
* mismatch — callers should wrap in try/catch to translate to their
|
|
334
|
+
* runner's fail helper.
|
|
335
|
+
*/
|
|
336
|
+
export async function expectNavigation(
|
|
337
|
+
page: PlaywrightLikePage,
|
|
338
|
+
expectation: ExpectNavigationInput,
|
|
339
|
+
): Promise<ExpectNavigationResult> {
|
|
340
|
+
const timeoutMs = expectation.timeoutMs ?? 5000;
|
|
341
|
+
const chain: string[] = [];
|
|
342
|
+
let firstUrl: string | null = null;
|
|
343
|
+
|
|
344
|
+
const onNav = (frame: { url: () => string }) => {
|
|
345
|
+
const u = frame.url();
|
|
346
|
+
if (firstUrl === null) firstUrl = u;
|
|
347
|
+
// Only append when the URL actually changes.
|
|
348
|
+
if (chain.length === 0 || chain[chain.length - 1] !== u) {
|
|
349
|
+
chain.push(u);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
page.on("framenavigated", onNav);
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
// Seed the chain with the current URL before waiting, so synchronous
|
|
356
|
+
// navigations are captured even if `framenavigated` fires before we
|
|
357
|
+
// attach.
|
|
358
|
+
const current = page.url();
|
|
359
|
+
if (current) chain.push(current);
|
|
360
|
+
|
|
361
|
+
if (page.waitForURL) {
|
|
362
|
+
try {
|
|
363
|
+
await page.waitForURL(expectation.to, { timeout: timeoutMs });
|
|
364
|
+
} catch {
|
|
365
|
+
// fall through — we'll diagnose below
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
// Fallback poll when waitForURL isn't available (mock pages etc).
|
|
369
|
+
const deadline = Date.now() + timeoutMs;
|
|
370
|
+
while (Date.now() < deadline) {
|
|
371
|
+
if (urlMatches(page.url(), expectation.to)) break;
|
|
372
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const finalUrl = page.url();
|
|
377
|
+
if (chain.length === 0 || chain[chain.length - 1] !== finalUrl) {
|
|
378
|
+
chain.push(finalUrl);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Validate `from`.
|
|
382
|
+
if (expectation.from !== undefined) {
|
|
383
|
+
const first = chain[0];
|
|
384
|
+
if (first && !urlMatches(first, expectation.from)) {
|
|
385
|
+
throw new NavigationAssertionError(
|
|
386
|
+
`expectNavigation: starting URL mismatch. Expected ${expectation.from}, got ${first}`,
|
|
387
|
+
{ from: expectation.from, expectedTo: String(expectation.to), actualTo: finalUrl, chain },
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Validate final URL.
|
|
393
|
+
if (!urlMatches(finalUrl, expectation.to)) {
|
|
394
|
+
throw new NavigationAssertionError(
|
|
395
|
+
`expectNavigation: terminal URL mismatch. Expected ${expectation.to}, got ${finalUrl}`,
|
|
396
|
+
{ from: expectation.from ?? "", expectedTo: String(expectation.to), actualTo: finalUrl, chain },
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Validate chain length.
|
|
401
|
+
// Redirect count excludes the starting URL itself when `from` is set.
|
|
402
|
+
const chainHops = expectation.from !== undefined ? Math.max(0, chain.length - 1) : chain.length;
|
|
403
|
+
if (expectation.redirectCount !== undefined && chainHops !== expectation.redirectCount) {
|
|
404
|
+
throw new NavigationAssertionError(
|
|
405
|
+
`expectNavigation: redirect count mismatch. Expected exactly ${expectation.redirectCount}, got ${chainHops}`,
|
|
406
|
+
{ from: expectation.from ?? "", expectedTo: String(expectation.to), actualTo: finalUrl, chain },
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (expectation.maxRedirects !== undefined && chainHops > expectation.maxRedirects) {
|
|
410
|
+
throw new NavigationAssertionError(
|
|
411
|
+
`expectNavigation: redirect chain too long. Expected ≤ ${expectation.maxRedirects}, got ${chainHops}`,
|
|
412
|
+
{ from: expectation.from ?? "", expectedTo: String(expectation.to), actualTo: finalUrl, chain },
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { status: "pass", chain, finalUrl };
|
|
417
|
+
} finally {
|
|
418
|
+
if (page.off) {
|
|
419
|
+
try {
|
|
420
|
+
page.off("framenavigated", onNav);
|
|
421
|
+
} catch {
|
|
422
|
+
// ignore
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export class NavigationAssertionError extends Error {
|
|
429
|
+
/** Shape matches `failure.v1` `redirect_unexpected` detail. */
|
|
430
|
+
detail: {
|
|
431
|
+
from: string;
|
|
432
|
+
expectedTo: string;
|
|
433
|
+
actualTo: string;
|
|
434
|
+
chain: string[];
|
|
435
|
+
};
|
|
436
|
+
kind = "redirect_unexpected" as const;
|
|
437
|
+
constructor(
|
|
438
|
+
message: string,
|
|
439
|
+
detail: { from: string; expectedTo: string; actualTo: string; chain: string[] },
|
|
440
|
+
) {
|
|
441
|
+
super(message);
|
|
442
|
+
this.name = "NavigationAssertionError";
|
|
443
|
+
this.detail = detail;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function urlMatches(actual: string, expected: string | RegExp): boolean {
|
|
448
|
+
if (expected instanceof RegExp) return expected.test(actual);
|
|
449
|
+
// String match — treat as substring OR exact. "/kr" should match
|
|
450
|
+
// "http://localhost/kr" too.
|
|
451
|
+
if (actual === expected) return true;
|
|
452
|
+
try {
|
|
453
|
+
const parsed = new URL(actual);
|
|
454
|
+
if (parsed.pathname === expected) return true;
|
|
455
|
+
if (parsed.pathname + parsed.search === expected) return true;
|
|
456
|
+
} catch {
|
|
457
|
+
// fall through
|
|
458
|
+
}
|
|
459
|
+
return actual.includes(expected);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
463
|
+
// waitForIsland
|
|
464
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
export interface WaitForIslandOptions {
|
|
467
|
+
timeoutMs?: number;
|
|
468
|
+
/** "hydrated" (default) or "visible". "visible" only checks mount. */
|
|
469
|
+
state?: "hydrated" | "visible";
|
|
470
|
+
/**
|
|
471
|
+
* Override how islands expose their strategy. When this returns
|
|
472
|
+
* "none" we short-circuit and resolve immediately.
|
|
473
|
+
*/
|
|
474
|
+
strategyOf?: (page: PlaywrightIslandPage, name: string) => Promise<"none" | "other" | null>;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export interface PlaywrightIslandPage {
|
|
478
|
+
/**
|
|
479
|
+
* Invoke a JS function in the browser context. We use
|
|
480
|
+
* `page.evaluate(fn, arg)` — same signature as Playwright's `Page`.
|
|
481
|
+
*/
|
|
482
|
+
evaluate<T, A>(fn: (arg: A) => T, arg: A): Promise<T>;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Wait until `[data-island="<name>"]` is hydrated.
|
|
487
|
+
*
|
|
488
|
+
* `hydration:none` strategy islands resolve immediately (Mandu's SSR
|
|
489
|
+
* emits `data-island-strategy="none"` for those — we check that first
|
|
490
|
+
* and short-circuit).
|
|
491
|
+
*
|
|
492
|
+
* Primary signal: `data-hydrated="true"` attribute (the one emitted by
|
|
493
|
+
* `@mandujs/core/client/hydrate`). Fallback: `data-island-state="hydrated"`.
|
|
494
|
+
*/
|
|
495
|
+
export async function waitForIsland(
|
|
496
|
+
page: PlaywrightIslandPage,
|
|
497
|
+
name: string,
|
|
498
|
+
options: WaitForIslandOptions = {},
|
|
499
|
+
): Promise<void> {
|
|
500
|
+
const timeoutMs = options.timeoutMs ?? 3000;
|
|
501
|
+
const state = options.state ?? "hydrated";
|
|
502
|
+
|
|
503
|
+
// Short-circuit for hydration:none islands.
|
|
504
|
+
try {
|
|
505
|
+
const strategy = await page.evaluate(
|
|
506
|
+
(n: string) => {
|
|
507
|
+
const el = document.querySelector(`[data-island="${n}"]`);
|
|
508
|
+
if (!el) return null;
|
|
509
|
+
const s = el.getAttribute("data-island-strategy");
|
|
510
|
+
return s ?? null;
|
|
511
|
+
},
|
|
512
|
+
name,
|
|
513
|
+
);
|
|
514
|
+
if (strategy === "none") {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
} catch {
|
|
518
|
+
// fall through — evaluate may be unsupported in mocks
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const deadline = Date.now() + timeoutMs;
|
|
522
|
+
const pollInterval = 25;
|
|
523
|
+
|
|
524
|
+
while (Date.now() < deadline) {
|
|
525
|
+
try {
|
|
526
|
+
const status = await page.evaluate(
|
|
527
|
+
(args: { name: string; state: string }) => {
|
|
528
|
+
const el = document.querySelector(`[data-island="${args.name}"]`);
|
|
529
|
+
if (!el) return { mounted: false, hydrated: false };
|
|
530
|
+
const mounted = true;
|
|
531
|
+
if (args.state === "visible") return { mounted, hydrated: mounted };
|
|
532
|
+
// primary
|
|
533
|
+
if (el.getAttribute("data-hydrated") === "true") {
|
|
534
|
+
return { mounted, hydrated: true };
|
|
535
|
+
}
|
|
536
|
+
// fallback
|
|
537
|
+
if (el.getAttribute("data-island-state") === "hydrated") {
|
|
538
|
+
return { mounted, hydrated: true };
|
|
539
|
+
}
|
|
540
|
+
return { mounted, hydrated: false };
|
|
541
|
+
},
|
|
542
|
+
{ name, state },
|
|
543
|
+
);
|
|
544
|
+
if (state === "visible" && status.mounted) return;
|
|
545
|
+
if (status.hydrated) return;
|
|
546
|
+
} catch {
|
|
547
|
+
// evaluate failure — treat as not-yet-ready and retry.
|
|
548
|
+
}
|
|
549
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
throw new HydrationTimeoutError(
|
|
553
|
+
`waitForIsland: island "${name}" did not reach state=${state} within ${timeoutMs}ms`,
|
|
554
|
+
{ island: name, waitedMs: timeoutMs },
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export class HydrationTimeoutError extends Error {
|
|
559
|
+
kind = "hydration_timeout" as const;
|
|
560
|
+
detail: { island: string; waitedMs: number };
|
|
561
|
+
constructor(message: string, detail: { island: string; waitedMs: number }) {
|
|
562
|
+
super(message);
|
|
563
|
+
this.name = "HydrationTimeoutError";
|
|
564
|
+
this.detail = detail;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
569
|
+
// assertStreamBoundary
|
|
570
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
export interface AssertStreamBoundaryInput {
|
|
573
|
+
/** Substrings the FIRST decoded chunk must contain (all of them). */
|
|
574
|
+
shellChunkContains?: string[];
|
|
575
|
+
/** Exact count of `<!--$-->` / `<!--/$-->` pairs. */
|
|
576
|
+
boundaryCount?: number;
|
|
577
|
+
/** First-chunk size guard (bytes). */
|
|
578
|
+
firstChunkMaxSizeBytes?: number;
|
|
579
|
+
/** Any one of these strings must appear in the final chunk. */
|
|
580
|
+
tailChunkContainsAnyOf?: string[];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export interface AssertStreamBoundaryResult {
|
|
584
|
+
status: "pass";
|
|
585
|
+
chunks: number;
|
|
586
|
+
totalBytes: number;
|
|
587
|
+
boundaryOpenCount: number;
|
|
588
|
+
boundaryCloseCount: number;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Consume a streaming Response chunk-by-chunk and validate boundary
|
|
593
|
+
* markers / shell content / byte budgets.
|
|
594
|
+
*
|
|
595
|
+
* Throws `StreamBoundaryError` (failure.v1-shaped) on mismatch.
|
|
596
|
+
*/
|
|
597
|
+
export async function assertStreamBoundary(
|
|
598
|
+
response: Response,
|
|
599
|
+
expectations: AssertStreamBoundaryInput,
|
|
600
|
+
): Promise<AssertStreamBoundaryResult> {
|
|
601
|
+
const body = response.body;
|
|
602
|
+
if (!body) {
|
|
603
|
+
throw new StreamBoundaryError("assertStreamBoundary: response has no body", {
|
|
604
|
+
reason: "no_body",
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const reader = body.getReader();
|
|
609
|
+
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
610
|
+
const chunks: string[] = [];
|
|
611
|
+
let totalBytes = 0;
|
|
612
|
+
let firstChunkBytes = 0;
|
|
613
|
+
|
|
614
|
+
try {
|
|
615
|
+
// eslint-disable-next-line no-constant-condition
|
|
616
|
+
while (true) {
|
|
617
|
+
const { done, value } = await reader.read();
|
|
618
|
+
if (done) break;
|
|
619
|
+
if (!value) continue;
|
|
620
|
+
const bytes = value.byteLength;
|
|
621
|
+
totalBytes += bytes;
|
|
622
|
+
if (chunks.length === 0) firstChunkBytes = bytes;
|
|
623
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
624
|
+
}
|
|
625
|
+
// flush trailing decoder state
|
|
626
|
+
chunks.push(decoder.decode());
|
|
627
|
+
} finally {
|
|
628
|
+
try {
|
|
629
|
+
reader.releaseLock();
|
|
630
|
+
} catch {
|
|
631
|
+
// ignore
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const shell = chunks[0] ?? "";
|
|
636
|
+
// Walk back to find the last non-empty chunk. The TextDecoder's final
|
|
637
|
+
// flush can append an empty string — that would give us a useless tail.
|
|
638
|
+
let tail = "";
|
|
639
|
+
for (let i = chunks.length - 1; i >= 0; i--) {
|
|
640
|
+
if (chunks[i] && chunks[i].length > 0) {
|
|
641
|
+
tail = chunks[i];
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (!tail) tail = chunks.join("");
|
|
646
|
+
const full = chunks.join("");
|
|
647
|
+
|
|
648
|
+
// shellChunkContains
|
|
649
|
+
if (expectations.shellChunkContains) {
|
|
650
|
+
for (const needle of expectations.shellChunkContains) {
|
|
651
|
+
if (!shell.includes(needle)) {
|
|
652
|
+
throw new StreamBoundaryError(
|
|
653
|
+
`assertStreamBoundary: first chunk missing expected content "${needle}"`,
|
|
654
|
+
{
|
|
655
|
+
reason: "shell_missing_content",
|
|
656
|
+
missing: needle,
|
|
657
|
+
shellPreview: shell.slice(0, 200),
|
|
658
|
+
},
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// firstChunkMaxSizeBytes
|
|
665
|
+
if (
|
|
666
|
+
expectations.firstChunkMaxSizeBytes !== undefined &&
|
|
667
|
+
firstChunkBytes > expectations.firstChunkMaxSizeBytes
|
|
668
|
+
) {
|
|
669
|
+
throw new StreamBoundaryError(
|
|
670
|
+
`assertStreamBoundary: first chunk ${firstChunkBytes} bytes exceeds budget ${expectations.firstChunkMaxSizeBytes}`,
|
|
671
|
+
{
|
|
672
|
+
reason: "shell_over_budget",
|
|
673
|
+
firstChunkBytes,
|
|
674
|
+
budget: expectations.firstChunkMaxSizeBytes,
|
|
675
|
+
},
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// boundary count — count occurrences of `<!--$-->` (open) and
|
|
680
|
+
// `<!--/$-->` (close). A "boundary" is one open+close pair, so we
|
|
681
|
+
// expect the open count to equal the close count AND equal
|
|
682
|
+
// `expectations.boundaryCount`.
|
|
683
|
+
const openCount = countOccurrences(full, "<!--$-->");
|
|
684
|
+
const closeCount = countOccurrences(full, "<!--/$-->");
|
|
685
|
+
if (
|
|
686
|
+
expectations.boundaryCount !== undefined &&
|
|
687
|
+
openCount !== expectations.boundaryCount
|
|
688
|
+
) {
|
|
689
|
+
throw new StreamBoundaryError(
|
|
690
|
+
`assertStreamBoundary: expected ${expectations.boundaryCount} Suspense boundaries, saw ${openCount} open / ${closeCount} close`,
|
|
691
|
+
{ reason: "boundary_count_mismatch", expected: expectations.boundaryCount, openCount, closeCount },
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// tailChunkContainsAnyOf
|
|
696
|
+
if (expectations.tailChunkContainsAnyOf && expectations.tailChunkContainsAnyOf.length > 0) {
|
|
697
|
+
const hit = expectations.tailChunkContainsAnyOf.some((n) => tail.includes(n));
|
|
698
|
+
if (!hit) {
|
|
699
|
+
throw new StreamBoundaryError(
|
|
700
|
+
`assertStreamBoundary: tail chunk missing any of [${expectations.tailChunkContainsAnyOf.join(", ")}]`,
|
|
701
|
+
{
|
|
702
|
+
reason: "tail_missing_content",
|
|
703
|
+
candidates: expectations.tailChunkContainsAnyOf,
|
|
704
|
+
tailPreview: tail.slice(-200),
|
|
705
|
+
},
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return {
|
|
711
|
+
status: "pass",
|
|
712
|
+
chunks: chunks.length,
|
|
713
|
+
totalBytes,
|
|
714
|
+
boundaryOpenCount: openCount,
|
|
715
|
+
boundaryCloseCount: closeCount,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export class StreamBoundaryError extends Error {
|
|
720
|
+
kind = "stream_boundary_mismatch" as const;
|
|
721
|
+
detail: Record<string, unknown>;
|
|
722
|
+
constructor(message: string, detail: Record<string, unknown>) {
|
|
723
|
+
super(message);
|
|
724
|
+
this.name = "StreamBoundaryError";
|
|
725
|
+
this.detail = detail;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function countOccurrences(haystack: string, needle: string): number {
|
|
730
|
+
if (!needle) return 0;
|
|
731
|
+
let count = 0;
|
|
732
|
+
let idx = 0;
|
|
733
|
+
while ((idx = haystack.indexOf(needle, idx)) !== -1) {
|
|
734
|
+
count++;
|
|
735
|
+
idx += needle.length;
|
|
736
|
+
}
|
|
737
|
+
return count;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
741
|
+
// expectSemantic — agent-delegated oracle
|
|
742
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
743
|
+
|
|
744
|
+
export interface ExpectSemanticOptions {
|
|
745
|
+
/** What to capture. Default "both". */
|
|
746
|
+
capture?: "screenshot" | "dom" | "both";
|
|
747
|
+
/** Treat the queueing as non-blocking (default). */
|
|
748
|
+
deferToAgent?: boolean;
|
|
749
|
+
/**
|
|
750
|
+
* When true, past `failed` verdicts for the same claim promote to a
|
|
751
|
+
* deterministic fail on this run. Default false. Per §C.1.5.
|
|
752
|
+
*/
|
|
753
|
+
promoteVerdicts?: boolean;
|
|
754
|
+
/** Override the repo root — default `process.cwd()`. */
|
|
755
|
+
repoRoot?: string;
|
|
756
|
+
/** Override the spec path recorded in the queue entry. */
|
|
757
|
+
specPath?: string;
|
|
758
|
+
/** Override the runId recorded in the queue entry. */
|
|
759
|
+
runId?: string;
|
|
760
|
+
/** Inject a fixed timestamp — for goldens / tests. */
|
|
761
|
+
now?: () => string;
|
|
762
|
+
/**
|
|
763
|
+
* Optional DOM snapshot — tests pass it in explicitly so we don't have
|
|
764
|
+
* to stand up a headless browser. In real use this is captured via
|
|
765
|
+
* `page.content()`.
|
|
766
|
+
*/
|
|
767
|
+
domSnapshot?: string;
|
|
768
|
+
/**
|
|
769
|
+
* Optional screenshot bytes — tests pass it in explicitly so we don't
|
|
770
|
+
* require Playwright at callers.
|
|
771
|
+
*/
|
|
772
|
+
screenshotBytes?: Uint8Array;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export interface ExpectSemanticPage {
|
|
776
|
+
/** Playwright Page.content(). */
|
|
777
|
+
content?(): Promise<string>;
|
|
778
|
+
/** Playwright Page.screenshot() — returns binary. */
|
|
779
|
+
screenshot?(opts?: Record<string, unknown>): Promise<Uint8Array>;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export interface OracleQueueEntry {
|
|
783
|
+
assertionId: string;
|
|
784
|
+
specPath: string;
|
|
785
|
+
runId: string;
|
|
786
|
+
claim: string;
|
|
787
|
+
artifactPath: string;
|
|
788
|
+
status: "pending" | "passed" | "failed";
|
|
789
|
+
verdict?: {
|
|
790
|
+
judgedBy: "agent" | "human";
|
|
791
|
+
reason: string;
|
|
792
|
+
timestamp: string;
|
|
793
|
+
};
|
|
794
|
+
timestamp: string;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export interface ExpectSemanticResult {
|
|
798
|
+
status: "pass" | "fail";
|
|
799
|
+
assertionId: string;
|
|
800
|
+
/** Set when a past verdict triggered a promoted deterministic fail. */
|
|
801
|
+
promotedFromVerdict?: OracleQueueEntry["verdict"];
|
|
802
|
+
/** True when we skipped queueing (CI / DETERMINISTIC_ONLY). */
|
|
803
|
+
deferred: boolean;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Queue a semantic claim for agent judgment.
|
|
808
|
+
*
|
|
809
|
+
* Default behaviour is **non-blocking** — we enqueue to
|
|
810
|
+
* `.mandu/ate-oracle-queue.jsonl`, return `status: "pass"`, and let a
|
|
811
|
+
* later agent session judge via `mandu_ate_oracle_verdict`.
|
|
812
|
+
*
|
|
813
|
+
* Two escape hatches:
|
|
814
|
+
* - `MANDU_ATE_DETERMINISTIC_ONLY=1` → no file writes, return immediately.
|
|
815
|
+
* - `promoteVerdicts: true` + past `failed` verdict for the same claim
|
|
816
|
+
* → throw `SemanticDivergenceError` to regress the spec.
|
|
817
|
+
*/
|
|
818
|
+
export function expectSemantic(
|
|
819
|
+
page: ExpectSemanticPage,
|
|
820
|
+
claim: string,
|
|
821
|
+
options: ExpectSemanticOptions = {},
|
|
822
|
+
): ExpectSemanticResult {
|
|
823
|
+
const repoRoot = options.repoRoot ?? process.cwd();
|
|
824
|
+
const deterministicOnly = process.env.MANDU_ATE_DETERMINISTIC_ONLY === "1";
|
|
825
|
+
|
|
826
|
+
// Compute a stable assertionId — hash of (claim + specPath). This is
|
|
827
|
+
// how promoteVerdicts matches past verdicts without relying on random
|
|
828
|
+
// ids.
|
|
829
|
+
const specPath = options.specPath ?? inferSpecPath();
|
|
830
|
+
const runId = options.runId ?? deriveRunId();
|
|
831
|
+
const assertionId = stableAssertionId(claim, specPath);
|
|
832
|
+
|
|
833
|
+
// promoteVerdicts — scan existing queue for the same assertionId.
|
|
834
|
+
if (options.promoteVerdicts) {
|
|
835
|
+
const past = findPastVerdict(repoRoot, assertionId);
|
|
836
|
+
if (past && past.status === "failed" && past.verdict) {
|
|
837
|
+
throw new SemanticDivergenceError(
|
|
838
|
+
`expectSemantic: past verdict flagged this claim as failed. claim="${claim}" reason="${past.verdict.reason}"`,
|
|
839
|
+
{ claim, evidence: past.artifactPath, oraclePending: false },
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (deterministicOnly) {
|
|
845
|
+
return {
|
|
846
|
+
status: "pass",
|
|
847
|
+
assertionId,
|
|
848
|
+
deferred: true,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Create artifact dir + write captures.
|
|
853
|
+
const nowStr = options.now ? options.now() : new Date().toISOString();
|
|
854
|
+
const artifactDir = join(repoRoot, ".mandu", "ate-oracle-queue", runId, assertionId);
|
|
855
|
+
try {
|
|
856
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
857
|
+
} catch {
|
|
858
|
+
// fall through — queue is best-effort
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const capture = options.capture ?? "both";
|
|
862
|
+
// Pull from explicit options first (test path), fall back to page calls.
|
|
863
|
+
const domPromise =
|
|
864
|
+
options.domSnapshot !== undefined
|
|
865
|
+
? Promise.resolve(options.domSnapshot)
|
|
866
|
+
: capture === "screenshot" || !page.content
|
|
867
|
+
? Promise.resolve(null)
|
|
868
|
+
: page.content().catch(() => null);
|
|
869
|
+
const screenshotPromise =
|
|
870
|
+
options.screenshotBytes !== undefined
|
|
871
|
+
? Promise.resolve(options.screenshotBytes)
|
|
872
|
+
: capture === "dom" || !page.screenshot
|
|
873
|
+
? Promise.resolve(null)
|
|
874
|
+
: page.screenshot().catch(() => null);
|
|
875
|
+
|
|
876
|
+
// Write captures synchronously after they resolve. expectSemantic is
|
|
877
|
+
// declared synchronous in §C.1.5 — but file writes here are also
|
|
878
|
+
// expected to not block on network, so we kick off a microtask and
|
|
879
|
+
// return. This matches the spec's "non-blocking for CI" contract.
|
|
880
|
+
void Promise.all([domPromise, screenshotPromise]).then(([dom, shot]) => {
|
|
881
|
+
try {
|
|
882
|
+
if (dom !== null && dom !== undefined) {
|
|
883
|
+
writeFileSync(join(artifactDir, "dom.html"), dom, "utf8");
|
|
884
|
+
}
|
|
885
|
+
if (shot) {
|
|
886
|
+
writeFileSync(join(artifactDir, "screenshot.png"), Buffer.from(shot));
|
|
887
|
+
}
|
|
888
|
+
} catch {
|
|
889
|
+
// swallow
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
// Append the queue entry immediately so agents see it before the
|
|
894
|
+
// artifacts finish writing.
|
|
895
|
+
const entry: OracleQueueEntry = {
|
|
896
|
+
assertionId,
|
|
897
|
+
specPath,
|
|
898
|
+
runId,
|
|
899
|
+
claim,
|
|
900
|
+
artifactPath: artifactDir.replace(/\\/g, "/"),
|
|
901
|
+
status: "pending",
|
|
902
|
+
timestamp: nowStr,
|
|
903
|
+
};
|
|
904
|
+
try {
|
|
905
|
+
const queuePath = join(repoRoot, ".mandu", "ate-oracle-queue.jsonl");
|
|
906
|
+
mkdirSync(dirname(queuePath), { recursive: true });
|
|
907
|
+
appendFileSync(queuePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
908
|
+
} catch {
|
|
909
|
+
// swallow — queue write is best-effort.
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return {
|
|
913
|
+
status: "pass",
|
|
914
|
+
assertionId,
|
|
915
|
+
deferred: true,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
export class SemanticDivergenceError extends Error {
|
|
920
|
+
kind = "semantic_divergence" as const;
|
|
921
|
+
detail: { claim: string; evidence?: string; oraclePending: boolean };
|
|
922
|
+
constructor(
|
|
923
|
+
message: string,
|
|
924
|
+
detail: { claim: string; evidence?: string; oraclePending: boolean },
|
|
925
|
+
) {
|
|
926
|
+
super(message);
|
|
927
|
+
this.name = "SemanticDivergenceError";
|
|
928
|
+
this.detail = detail;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function stableAssertionId(claim: string, specPath: string): string {
|
|
933
|
+
// Small deterministic hash — DJB2 variant. Don't need cryptographic
|
|
934
|
+
// strength; we just need stable ids across runs.
|
|
935
|
+
const input = `${specPath}|${claim}`;
|
|
936
|
+
let hash = 5381;
|
|
937
|
+
for (let i = 0; i < input.length; i++) {
|
|
938
|
+
hash = ((hash << 5) + hash + input.charCodeAt(i)) >>> 0;
|
|
939
|
+
}
|
|
940
|
+
return `sem-${hash.toString(16).padStart(8, "0")}`;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function inferSpecPath(): string {
|
|
944
|
+
// Best-effort: peek at the stack for the first frame outside this file.
|
|
945
|
+
const err = new Error();
|
|
946
|
+
const stack = err.stack ?? "";
|
|
947
|
+
const lines = stack.split("\n");
|
|
948
|
+
for (const line of lines) {
|
|
949
|
+
if (line.includes("assertions.ts")) continue;
|
|
950
|
+
const match = line.match(/\(([^)]+?):(\d+):(\d+)\)/) || line.match(/at\s+([^\s]+?):(\d+):(\d+)/);
|
|
951
|
+
if (match) {
|
|
952
|
+
const file = match[1];
|
|
953
|
+
if (isAbsolute(file) && !file.includes("node_modules")) {
|
|
954
|
+
return file.replace(/\\/g, "/");
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return "unknown.spec.ts";
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function deriveRunId(): string {
|
|
962
|
+
return process.env.MANDU_ATE_RUN_ID ?? `run-${Date.now().toString(36)}`;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function findPastVerdict(repoRoot: string, assertionId: string): OracleQueueEntry | null {
|
|
966
|
+
const path = join(repoRoot, ".mandu", "ate-oracle-queue.jsonl");
|
|
967
|
+
if (!existsSync(path)) return null;
|
|
968
|
+
try {
|
|
969
|
+
const content = readFileSync(path, "utf8");
|
|
970
|
+
// Walk from the tail — most recent verdict for this id wins.
|
|
971
|
+
const lines = content.split("\n").filter(Boolean);
|
|
972
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
973
|
+
try {
|
|
974
|
+
const e = JSON.parse(lines[i]) as OracleQueueEntry;
|
|
975
|
+
if (e.assertionId === assertionId && e.status !== "pending") {
|
|
976
|
+
return e;
|
|
977
|
+
}
|
|
978
|
+
} catch {
|
|
979
|
+
// skip corrupt line
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
} catch {
|
|
983
|
+
// swallow
|
|
984
|
+
}
|
|
985
|
+
return null;
|
|
986
|
+
}
|