@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,632 +1,632 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase C.1 — tests for the Mandu-specific assertion primitives.
|
|
3
|
-
*
|
|
4
|
-
* Each primitive has 6-8 cases per spec §C.1. They keep the entire suite
|
|
5
|
-
* hermetic — no network, no Playwright process, no real streaming server.
|
|
6
|
-
* Tests that need a page/response supply minimal mocks of the
|
|
7
|
-
* `Page`/`Response` shape each primitive consumes.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
10
|
-
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
11
|
-
import { join, dirname } from "node:path";
|
|
12
|
-
import { tmpdir } from "node:os";
|
|
13
|
-
import { z } from "zod";
|
|
14
|
-
import {
|
|
15
|
-
expectContract,
|
|
16
|
-
expectNavigation,
|
|
17
|
-
waitForIsland,
|
|
18
|
-
assertStreamBoundary,
|
|
19
|
-
expectSemantic,
|
|
20
|
-
ContractAssertionError,
|
|
21
|
-
NavigationAssertionError,
|
|
22
|
-
HydrationTimeoutError,
|
|
23
|
-
StreamBoundaryError,
|
|
24
|
-
SemanticDivergenceError,
|
|
25
|
-
type PlaywrightLikePage,
|
|
26
|
-
type PlaywrightIslandPage,
|
|
27
|
-
type ExpectSemanticPage,
|
|
28
|
-
type OracleQueueEntry,
|
|
29
|
-
} from "../assertions";
|
|
30
|
-
|
|
31
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
32
|
-
// expectContract
|
|
33
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
describe("expectContract", () => {
|
|
36
|
-
const Signup = z.object({
|
|
37
|
-
userId: z.string().uuid(),
|
|
38
|
-
email: z.string().email(),
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("happy path — valid payload passes strict", () => {
|
|
42
|
-
const r = expectContract(
|
|
43
|
-
{ userId: "550e8400-e29b-41d4-a716-446655440000", email: "a@b.com" },
|
|
44
|
-
Signup,
|
|
45
|
-
);
|
|
46
|
-
expect(r.status).toBe("pass");
|
|
47
|
-
expect(r.violations.length).toBe(0);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("strict fails when a required field is missing", () => {
|
|
51
|
-
expect(() =>
|
|
52
|
-
expectContract({ email: "a@b.com" }, Signup),
|
|
53
|
-
).toThrow(ContractAssertionError);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("loose tolerates extra keys", () => {
|
|
57
|
-
const Open = Signup.passthrough();
|
|
58
|
-
const r = expectContract(
|
|
59
|
-
{ userId: "550e8400-e29b-41d4-a716-446655440000", email: "a@b.com", extra: 1 },
|
|
60
|
-
Open,
|
|
61
|
-
{ mode: "loose" },
|
|
62
|
-
);
|
|
63
|
-
expect(r.status).toBe("pass");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("drift-tolerant returns warnings without throwing", () => {
|
|
67
|
-
const r = expectContract(
|
|
68
|
-
{ userId: "not-a-uuid", email: "bad" },
|
|
69
|
-
Signup,
|
|
70
|
-
{ mode: "drift-tolerant" },
|
|
71
|
-
);
|
|
72
|
-
expect(r.status).toBe("fail");
|
|
73
|
-
expect(r.violations.every((v) => v.severity === "warning")).toBe(true);
|
|
74
|
-
expect(r.violations.length).toBeGreaterThan(0);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("ignorePaths skips the listed paths", () => {
|
|
78
|
-
// email violation is ignored; only userId would remain.
|
|
79
|
-
expect(() =>
|
|
80
|
-
expectContract({ userId: "not", email: "bad" }, Signup, {
|
|
81
|
-
ignorePaths: [".userId"],
|
|
82
|
-
}),
|
|
83
|
-
).toThrow(ContractAssertionError);
|
|
84
|
-
// But if we ignore both, it passes in loose mode (strict still flags extras only).
|
|
85
|
-
const r = expectContract(
|
|
86
|
-
{ userId: "550e8400-e29b-41d4-a716-446655440000", email: "a@b.com" },
|
|
87
|
-
Signup,
|
|
88
|
-
{ ignorePaths: [".userId", ".email"] },
|
|
89
|
-
);
|
|
90
|
-
expect(r.status).toBe("pass");
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("strict flags extra keys in the actual payload", () => {
|
|
94
|
-
// Zod default strips unknown keys — expectContract surfaces them as
|
|
95
|
-
// strict-mode violations by walking actual vs parsed.
|
|
96
|
-
expect(() =>
|
|
97
|
-
expectContract(
|
|
98
|
-
{ userId: "550e8400-e29b-41d4-a716-446655440000", email: "a@b.com", extra: 1 },
|
|
99
|
-
Signup,
|
|
100
|
-
{ mode: "strict" },
|
|
101
|
-
),
|
|
102
|
-
).toThrow(/extra key/i);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("violations carry machine-readable paths", () => {
|
|
106
|
-
try {
|
|
107
|
-
expectContract({ userId: "bad", email: "a@b.com" }, Signup);
|
|
108
|
-
throw new Error("should have thrown");
|
|
109
|
-
} catch (err) {
|
|
110
|
-
expect(err).toBeInstanceOf(ContractAssertionError);
|
|
111
|
-
const ce = err as ContractAssertionError;
|
|
112
|
-
expect(ce.violations[0].path).toBe(".userId");
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
118
|
-
// expectNavigation
|
|
119
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
120
|
-
|
|
121
|
-
interface MockPage extends PlaywrightLikePage {
|
|
122
|
-
__pushUrl(u: string): void;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function makeMockPage(initial: string): MockPage {
|
|
126
|
-
let current = initial;
|
|
127
|
-
const listeners: Array<(frame: { url: () => string }) => void> = [];
|
|
128
|
-
const page: MockPage = {
|
|
129
|
-
url: () => current,
|
|
130
|
-
on(event, listener) {
|
|
131
|
-
if (event === "framenavigated") listeners.push(listener as typeof listeners[number]);
|
|
132
|
-
},
|
|
133
|
-
off(event, listener) {
|
|
134
|
-
if (event !== "framenavigated") return;
|
|
135
|
-
const idx = listeners.indexOf(listener as typeof listeners[number]);
|
|
136
|
-
if (idx >= 0) listeners.splice(idx, 1);
|
|
137
|
-
},
|
|
138
|
-
async waitForURL(pattern) {
|
|
139
|
-
const deadline = Date.now() + 1000;
|
|
140
|
-
while (Date.now() < deadline) {
|
|
141
|
-
if (pattern instanceof RegExp ? pattern.test(current) : current === pattern || current.includes(String(pattern))) return;
|
|
142
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
143
|
-
}
|
|
144
|
-
},
|
|
145
|
-
__pushUrl(u) {
|
|
146
|
-
current = u;
|
|
147
|
-
for (const l of listeners) l({ url: () => u });
|
|
148
|
-
},
|
|
149
|
-
};
|
|
150
|
-
return page;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
describe("expectNavigation", () => {
|
|
154
|
-
test("passes when final URL matches regex", async () => {
|
|
155
|
-
const page = makeMockPage("http://localhost/");
|
|
156
|
-
setTimeout(() => page.__pushUrl("http://localhost/kr"), 5);
|
|
157
|
-
const r = await expectNavigation(page, { from: "http://localhost/", to: /\/kr(\/|$)/ });
|
|
158
|
-
expect(r.status).toBe("pass");
|
|
159
|
-
expect(r.chain.some((u) => u.endsWith("/kr"))).toBe(true);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("passes exact redirect count", async () => {
|
|
163
|
-
const page = makeMockPage("http://localhost/");
|
|
164
|
-
setTimeout(() => page.__pushUrl("http://localhost/kr"), 5);
|
|
165
|
-
setTimeout(() => page.__pushUrl("http://localhost/kr/home"), 10);
|
|
166
|
-
const r = await expectNavigation(page, {
|
|
167
|
-
from: "http://localhost/",
|
|
168
|
-
to: /\/kr\/home/,
|
|
169
|
-
redirectCount: 2,
|
|
170
|
-
timeoutMs: 200,
|
|
171
|
-
});
|
|
172
|
-
expect(r.status).toBe("pass");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("fails when final URL mismatches", async () => {
|
|
176
|
-
const page = makeMockPage("http://localhost/");
|
|
177
|
-
let threw = false;
|
|
178
|
-
try {
|
|
179
|
-
await expectNavigation(page, { to: /\/never-matches/, timeoutMs: 50 });
|
|
180
|
-
} catch (err) {
|
|
181
|
-
threw = true;
|
|
182
|
-
expect(err).toBeInstanceOf(NavigationAssertionError);
|
|
183
|
-
}
|
|
184
|
-
expect(threw).toBe(true);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test("captures chain for later diagnosis", async () => {
|
|
188
|
-
const page = makeMockPage("http://localhost/");
|
|
189
|
-
setTimeout(() => page.__pushUrl("http://localhost/a"), 5);
|
|
190
|
-
setTimeout(() => page.__pushUrl("http://localhost/b"), 10);
|
|
191
|
-
try {
|
|
192
|
-
await expectNavigation(page, { to: /\/NEVER/, timeoutMs: 50 });
|
|
193
|
-
throw new Error("unreachable");
|
|
194
|
-
} catch (err) {
|
|
195
|
-
expect(err).toBeInstanceOf(NavigationAssertionError);
|
|
196
|
-
const ne = err as NavigationAssertionError;
|
|
197
|
-
expect(ne.detail.chain.length).toBeGreaterThanOrEqual(2);
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test("fails when redirectCount mismatches", async () => {
|
|
202
|
-
const page = makeMockPage("http://localhost/");
|
|
203
|
-
setTimeout(() => page.__pushUrl("http://localhost/x"), 5);
|
|
204
|
-
setTimeout(() => page.__pushUrl("http://localhost/y"), 10);
|
|
205
|
-
let threw = false;
|
|
206
|
-
try {
|
|
207
|
-
await expectNavigation(page, {
|
|
208
|
-
from: "http://localhost/",
|
|
209
|
-
to: /\/y/,
|
|
210
|
-
redirectCount: 1,
|
|
211
|
-
timeoutMs: 200,
|
|
212
|
-
});
|
|
213
|
-
} catch (err) {
|
|
214
|
-
threw = true;
|
|
215
|
-
expect(err).toBeInstanceOf(NavigationAssertionError);
|
|
216
|
-
}
|
|
217
|
-
expect(threw).toBe(true);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
test("enforces maxRedirects ≤", async () => {
|
|
221
|
-
const page = makeMockPage("http://localhost/");
|
|
222
|
-
setTimeout(() => page.__pushUrl("http://localhost/a"), 5);
|
|
223
|
-
setTimeout(() => page.__pushUrl("http://localhost/b"), 10);
|
|
224
|
-
setTimeout(() => page.__pushUrl("http://localhost/c"), 15);
|
|
225
|
-
let threw = false;
|
|
226
|
-
try {
|
|
227
|
-
await expectNavigation(page, {
|
|
228
|
-
from: "http://localhost/",
|
|
229
|
-
to: /\/c/,
|
|
230
|
-
maxRedirects: 1,
|
|
231
|
-
timeoutMs: 200,
|
|
232
|
-
});
|
|
233
|
-
} catch (err) {
|
|
234
|
-
threw = true;
|
|
235
|
-
}
|
|
236
|
-
expect(threw).toBe(true);
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
241
|
-
// waitForIsland
|
|
242
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
243
|
-
|
|
244
|
-
function makeIslandPage(state: { strategy?: string; hydrated?: boolean; mounted?: boolean; delayMs?: number }): PlaywrightIslandPage & {
|
|
245
|
-
hydrate: () => void;
|
|
246
|
-
} {
|
|
247
|
-
const s = { mounted: true, hydrated: false, ...state };
|
|
248
|
-
let hydrateTick = 0;
|
|
249
|
-
return {
|
|
250
|
-
async evaluate<T, A>(fn: (arg: A) => T, arg: A): Promise<T> {
|
|
251
|
-
// We don't actually run `fn` in a browser — we inspect the shape
|
|
252
|
-
// of `arg` and answer based on our mock state. The primitive's
|
|
253
|
-
// strategy-sniffing evaluate call passes a bare `name` (string);
|
|
254
|
-
// the polling evaluate call passes `{ name, state }`.
|
|
255
|
-
hydrateTick++;
|
|
256
|
-
if (state.delayMs && hydrateTick > 2) s.hydrated = true;
|
|
257
|
-
if (typeof arg === "string") {
|
|
258
|
-
// strategy sniff
|
|
259
|
-
return (s.strategy ?? null) as unknown as T;
|
|
260
|
-
}
|
|
261
|
-
if (!s.mounted) return { mounted: false, hydrated: false } as unknown as T;
|
|
262
|
-
if ((arg as { state: string }).state === "visible") return { mounted: true, hydrated: true } as unknown as T;
|
|
263
|
-
return { mounted: s.mounted, hydrated: s.hydrated } as unknown as T;
|
|
264
|
-
},
|
|
265
|
-
hydrate() {
|
|
266
|
-
s.hydrated = true;
|
|
267
|
-
},
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
describe("waitForIsland", () => {
|
|
272
|
-
test("resolves immediately for hydration:none strategy", async () => {
|
|
273
|
-
const page = makeIslandPage({ strategy: "none" });
|
|
274
|
-
const before = Date.now();
|
|
275
|
-
await waitForIsland(page, "Legal", { timeoutMs: 200 });
|
|
276
|
-
expect(Date.now() - before).toBeLessThan(200);
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
test("resolves when data-hydrated flips true", async () => {
|
|
280
|
-
const page = makeIslandPage({ strategy: "visible", delayMs: 50 });
|
|
281
|
-
await waitForIsland(page, "Cart", { timeoutMs: 500 });
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
test("throws HydrationTimeoutError after timeout", async () => {
|
|
285
|
-
const page = makeIslandPage({ strategy: "visible", hydrated: false });
|
|
286
|
-
let threw = false;
|
|
287
|
-
try {
|
|
288
|
-
await waitForIsland(page, "NeverHydrates", { timeoutMs: 100 });
|
|
289
|
-
} catch (err) {
|
|
290
|
-
threw = true;
|
|
291
|
-
expect(err).toBeInstanceOf(HydrationTimeoutError);
|
|
292
|
-
expect((err as HydrationTimeoutError).detail.island).toBe("NeverHydrates");
|
|
293
|
-
}
|
|
294
|
-
expect(threw).toBe(true);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test("state:visible passes once the island mounts", async () => {
|
|
298
|
-
const page = makeIslandPage({ strategy: "visible" });
|
|
299
|
-
await waitForIsland(page, "Banner", { timeoutMs: 100, state: "visible" });
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
test("evaluate exception retries the poll", async () => {
|
|
303
|
-
let calls = 0;
|
|
304
|
-
const page: PlaywrightIslandPage = {
|
|
305
|
-
async evaluate<T, A>(_fn: (arg: A) => T, arg: A): Promise<T> {
|
|
306
|
-
calls++;
|
|
307
|
-
if (calls === 1) throw new Error("transient");
|
|
308
|
-
if (typeof arg === "string") return "visible" as unknown as T;
|
|
309
|
-
return { mounted: true, hydrated: true } as unknown as T;
|
|
310
|
-
},
|
|
311
|
-
};
|
|
312
|
-
await waitForIsland(page, "Retry", { timeoutMs: 500 });
|
|
313
|
-
expect(calls).toBeGreaterThanOrEqual(2);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
test("detail payload has failure.v1 shape", async () => {
|
|
317
|
-
const page = makeIslandPage({ strategy: "visible", hydrated: false });
|
|
318
|
-
try {
|
|
319
|
-
await waitForIsland(page, "X", { timeoutMs: 30 });
|
|
320
|
-
throw new Error("unreachable");
|
|
321
|
-
} catch (err) {
|
|
322
|
-
const he = err as HydrationTimeoutError;
|
|
323
|
-
expect(he.kind).toBe("hydration_timeout");
|
|
324
|
-
expect(typeof he.detail.waitedMs).toBe("number");
|
|
325
|
-
}
|
|
326
|
-
});
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
330
|
-
// assertStreamBoundary
|
|
331
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
332
|
-
|
|
333
|
-
function makeStreamResponse(chunks: string[]): Response {
|
|
334
|
-
const encoder = new TextEncoder();
|
|
335
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
336
|
-
async start(controller) {
|
|
337
|
-
for (const c of chunks) {
|
|
338
|
-
controller.enqueue(encoder.encode(c));
|
|
339
|
-
}
|
|
340
|
-
controller.close();
|
|
341
|
-
},
|
|
342
|
-
});
|
|
343
|
-
return new Response(stream, { headers: { "Content-Type": "text/html" } });
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
describe("assertStreamBoundary", () => {
|
|
347
|
-
test("passes when shell and tail match", async () => {
|
|
348
|
-
const res = makeStreamResponse([
|
|
349
|
-
"<!DOCTYPE html><html><head></head><body>shell",
|
|
350
|
-
"<!--$--><div>slow</div><!--/$--><script>islands</script>",
|
|
351
|
-
]);
|
|
352
|
-
const r = await assertStreamBoundary(res, {
|
|
353
|
-
shellChunkContains: ["<!DOCTYPE", "<html"],
|
|
354
|
-
boundaryCount: 1,
|
|
355
|
-
tailChunkContainsAnyOf: ["<script"],
|
|
356
|
-
});
|
|
357
|
-
expect(r.status).toBe("pass");
|
|
358
|
-
expect(r.boundaryOpenCount).toBe(1);
|
|
359
|
-
expect(r.boundaryCloseCount).toBe(1);
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
test("counts multiple boundaries", async () => {
|
|
363
|
-
const res = makeStreamResponse([
|
|
364
|
-
"<html>",
|
|
365
|
-
"<!--$-->a<!--/$-->",
|
|
366
|
-
"<!--$-->b<!--/$-->",
|
|
367
|
-
"<!--$-->c<!--/$-->",
|
|
368
|
-
]);
|
|
369
|
-
const r = await assertStreamBoundary(res, { boundaryCount: 3 });
|
|
370
|
-
expect(r.boundaryOpenCount).toBe(3);
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
test("fails when boundaryCount mismatches", async () => {
|
|
374
|
-
const res = makeStreamResponse(["<html><!--$-->x<!--/$-->"]);
|
|
375
|
-
let threw = false;
|
|
376
|
-
try {
|
|
377
|
-
await assertStreamBoundary(res, { boundaryCount: 2 });
|
|
378
|
-
} catch (err) {
|
|
379
|
-
threw = true;
|
|
380
|
-
expect(err).toBeInstanceOf(StreamBoundaryError);
|
|
381
|
-
}
|
|
382
|
-
expect(threw).toBe(true);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
test("fails when shell missing expected marker", async () => {
|
|
386
|
-
const res = makeStreamResponse(["<html>body only"]);
|
|
387
|
-
let threw = false;
|
|
388
|
-
try {
|
|
389
|
-
await assertStreamBoundary(res, { shellChunkContains: ["<!DOCTYPE"] });
|
|
390
|
-
} catch (err) {
|
|
391
|
-
threw = true;
|
|
392
|
-
expect(err).toBeInstanceOf(StreamBoundaryError);
|
|
393
|
-
}
|
|
394
|
-
expect(threw).toBe(true);
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
test("fails when shell exceeds byte budget", async () => {
|
|
398
|
-
const big = "<html>" + "x".repeat(5_000);
|
|
399
|
-
const res = makeStreamResponse([big]);
|
|
400
|
-
let threw = false;
|
|
401
|
-
try {
|
|
402
|
-
await assertStreamBoundary(res, { firstChunkMaxSizeBytes: 1_000 });
|
|
403
|
-
} catch (err) {
|
|
404
|
-
threw = true;
|
|
405
|
-
expect(err).toBeInstanceOf(StreamBoundaryError);
|
|
406
|
-
}
|
|
407
|
-
expect(threw).toBe(true);
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
test("fails when tail misses all candidates", async () => {
|
|
411
|
-
const res = makeStreamResponse(["<html>", "end without script"]);
|
|
412
|
-
let threw = false;
|
|
413
|
-
try {
|
|
414
|
-
await assertStreamBoundary(res, { tailChunkContainsAnyOf: ["<script", "island"] });
|
|
415
|
-
} catch (err) {
|
|
416
|
-
threw = true;
|
|
417
|
-
}
|
|
418
|
-
expect(threw).toBe(true);
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
test("throws on empty body", async () => {
|
|
422
|
-
const res = new Response(null);
|
|
423
|
-
let threw = false;
|
|
424
|
-
try {
|
|
425
|
-
await assertStreamBoundary(res, {});
|
|
426
|
-
} catch {
|
|
427
|
-
threw = true;
|
|
428
|
-
}
|
|
429
|
-
expect(threw).toBe(true);
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
test("passes with no expectations (sanity)", async () => {
|
|
433
|
-
const res = makeStreamResponse(["<html>"]);
|
|
434
|
-
const r = await assertStreamBoundary(res, {});
|
|
435
|
-
expect(r.status).toBe("pass");
|
|
436
|
-
});
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
440
|
-
// expectSemantic
|
|
441
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
442
|
-
|
|
443
|
-
describe("expectSemantic", () => {
|
|
444
|
-
let tmp: string;
|
|
445
|
-
let priorDetOnly: string | undefined;
|
|
446
|
-
|
|
447
|
-
beforeEach(() => {
|
|
448
|
-
tmp = mkdtempSync(join(tmpdir(), "mandu-expect-semantic-"));
|
|
449
|
-
priorDetOnly = process.env.MANDU_ATE_DETERMINISTIC_ONLY;
|
|
450
|
-
delete process.env.MANDU_ATE_DETERMINISTIC_ONLY;
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
afterEach(() => {
|
|
454
|
-
if (priorDetOnly === undefined) {
|
|
455
|
-
delete process.env.MANDU_ATE_DETERMINISTIC_ONLY;
|
|
456
|
-
} else {
|
|
457
|
-
process.env.MANDU_ATE_DETERMINISTIC_ONLY = priorDetOnly;
|
|
458
|
-
}
|
|
459
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
function fakePage(): ExpectSemanticPage {
|
|
463
|
-
return {
|
|
464
|
-
async content() {
|
|
465
|
-
return "<html><body>hi</body></html>";
|
|
466
|
-
},
|
|
467
|
-
async screenshot() {
|
|
468
|
-
return new Uint8Array([1, 2, 3]);
|
|
469
|
-
},
|
|
470
|
-
};
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
test("enqueues a pending entry with pass status", async () => {
|
|
474
|
-
const page = fakePage();
|
|
475
|
-
const r = expectSemantic(page, "user sees error near field", {
|
|
476
|
-
repoRoot: tmp,
|
|
477
|
-
specPath: "tests/demo.spec.ts",
|
|
478
|
-
runId: "run-1",
|
|
479
|
-
});
|
|
480
|
-
expect(r.status).toBe("pass");
|
|
481
|
-
expect(r.deferred).toBe(true);
|
|
482
|
-
|
|
483
|
-
// Give the microtask a moment to flush artifacts.
|
|
484
|
-
await new Promise((r) => setTimeout(r, 20));
|
|
485
|
-
|
|
486
|
-
const queue = join(tmp, ".mandu", "ate-oracle-queue.jsonl");
|
|
487
|
-
expect(existsSync(queue)).toBe(true);
|
|
488
|
-
const line = readFileSync(queue, "utf8").trim();
|
|
489
|
-
const entry: OracleQueueEntry = JSON.parse(line);
|
|
490
|
-
expect(entry.status).toBe("pending");
|
|
491
|
-
expect(entry.claim).toContain("error");
|
|
492
|
-
expect(entry.assertionId).toBe(r.assertionId);
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
test("CI DETERMINISTIC_ONLY skips file writes entirely", () => {
|
|
496
|
-
process.env.MANDU_ATE_DETERMINISTIC_ONLY = "1";
|
|
497
|
-
const page = fakePage();
|
|
498
|
-
const r = expectSemantic(page, "non-blocking claim", {
|
|
499
|
-
repoRoot: tmp,
|
|
500
|
-
specPath: "tests/ci.spec.ts",
|
|
501
|
-
});
|
|
502
|
-
expect(r.status).toBe("pass");
|
|
503
|
-
expect(r.deferred).toBe(true);
|
|
504
|
-
const queue = join(tmp, ".mandu", "ate-oracle-queue.jsonl");
|
|
505
|
-
expect(existsSync(queue)).toBe(false);
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
test("assertionId is stable across calls with same (claim, specPath)", () => {
|
|
509
|
-
const page = fakePage();
|
|
510
|
-
const a = expectSemantic(page, "same claim", { repoRoot: tmp, specPath: "x.spec.ts", runId: "1" });
|
|
511
|
-
const b = expectSemantic(page, "same claim", { repoRoot: tmp, specPath: "x.spec.ts", runId: "2" });
|
|
512
|
-
expect(a.assertionId).toBe(b.assertionId);
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
test("promoteVerdicts promotes past failed verdicts to runtime fail", async () => {
|
|
516
|
-
// Manually seed a past `failed` verdict with a matching assertionId.
|
|
517
|
-
const claim = "promote this one";
|
|
518
|
-
const specPath = "tests/promote.spec.ts";
|
|
519
|
-
// Use expectSemantic to generate the id deterministically, then
|
|
520
|
-
// write a synthetic `failed` entry before invoking again.
|
|
521
|
-
const first = expectSemantic(fakePage(), claim, {
|
|
522
|
-
repoRoot: tmp,
|
|
523
|
-
specPath,
|
|
524
|
-
runId: "r1",
|
|
525
|
-
});
|
|
526
|
-
const queuePath = join(tmp, ".mandu", "ate-oracle-queue.jsonl");
|
|
527
|
-
mkdirSync(dirname(queuePath), { recursive: true });
|
|
528
|
-
const failed: OracleQueueEntry = {
|
|
529
|
-
assertionId: first.assertionId,
|
|
530
|
-
specPath,
|
|
531
|
-
runId: "r1",
|
|
532
|
-
claim,
|
|
533
|
-
artifactPath: tmp,
|
|
534
|
-
status: "failed",
|
|
535
|
-
verdict: { judgedBy: "agent", reason: "missing error color", timestamp: new Date().toISOString() },
|
|
536
|
-
timestamp: new Date().toISOString(),
|
|
537
|
-
};
|
|
538
|
-
writeFileSync(queuePath, `${JSON.stringify(failed)}\n`);
|
|
539
|
-
|
|
540
|
-
// Now call with promoteVerdicts — should throw.
|
|
541
|
-
let threw = false;
|
|
542
|
-
try {
|
|
543
|
-
expectSemantic(fakePage(), claim, {
|
|
544
|
-
repoRoot: tmp,
|
|
545
|
-
specPath,
|
|
546
|
-
promoteVerdicts: true,
|
|
547
|
-
});
|
|
548
|
-
} catch (err) {
|
|
549
|
-
threw = true;
|
|
550
|
-
expect(err).toBeInstanceOf(SemanticDivergenceError);
|
|
551
|
-
}
|
|
552
|
-
expect(threw).toBe(true);
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
test("promoteVerdicts ignores pending + passed entries", () => {
|
|
556
|
-
const claim = "still open";
|
|
557
|
-
const specPath = "tests/pend.spec.ts";
|
|
558
|
-
const first = expectSemantic(fakePage(), claim, {
|
|
559
|
-
repoRoot: tmp,
|
|
560
|
-
specPath,
|
|
561
|
-
runId: "r1",
|
|
562
|
-
});
|
|
563
|
-
const queuePath = join(tmp, ".mandu", "ate-oracle-queue.jsonl");
|
|
564
|
-
const passed: OracleQueueEntry = {
|
|
565
|
-
assertionId: first.assertionId,
|
|
566
|
-
specPath,
|
|
567
|
-
runId: "r1",
|
|
568
|
-
claim,
|
|
569
|
-
artifactPath: tmp,
|
|
570
|
-
status: "passed",
|
|
571
|
-
verdict: { judgedBy: "agent", reason: "looks good", timestamp: new Date().toISOString() },
|
|
572
|
-
timestamp: new Date().toISOString(),
|
|
573
|
-
};
|
|
574
|
-
writeFileSync(queuePath, `${JSON.stringify(passed)}\n`);
|
|
575
|
-
|
|
576
|
-
// No throw — still pass.
|
|
577
|
-
const r = expectSemantic(fakePage(), claim, {
|
|
578
|
-
repoRoot: tmp,
|
|
579
|
-
specPath,
|
|
580
|
-
promoteVerdicts: true,
|
|
581
|
-
});
|
|
582
|
-
expect(r.status).toBe("pass");
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
test("capture:dom only writes dom.html", async () => {
|
|
586
|
-
const page = fakePage();
|
|
587
|
-
const r = expectSemantic(page, "dom only", {
|
|
588
|
-
repoRoot: tmp,
|
|
589
|
-
specPath: "tests/d.spec.ts",
|
|
590
|
-
runId: "run-dom",
|
|
591
|
-
capture: "dom",
|
|
592
|
-
});
|
|
593
|
-
await new Promise((r) => setTimeout(r, 30));
|
|
594
|
-
const artifactDir = join(tmp, ".mandu", "ate-oracle-queue", "run-dom", r.assertionId);
|
|
595
|
-
expect(existsSync(join(artifactDir, "dom.html"))).toBe(true);
|
|
596
|
-
expect(existsSync(join(artifactDir, "screenshot.png"))).toBe(false);
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
test("explicit injected snapshots bypass page calls", async () => {
|
|
600
|
-
// Page has no methods — we provide explicit artifacts.
|
|
601
|
-
const r = expectSemantic(
|
|
602
|
-
{},
|
|
603
|
-
"injected",
|
|
604
|
-
{
|
|
605
|
-
repoRoot: tmp,
|
|
606
|
-
specPath: "tests/inj.spec.ts",
|
|
607
|
-
runId: "inj-1",
|
|
608
|
-
domSnapshot: "<html><body>injected</body></html>",
|
|
609
|
-
screenshotBytes: new Uint8Array([9, 9]),
|
|
610
|
-
},
|
|
611
|
-
);
|
|
612
|
-
await new Promise((r) => setTimeout(r, 30));
|
|
613
|
-
const dir = join(tmp, ".mandu", "ate-oracle-queue", "inj-1", r.assertionId);
|
|
614
|
-
expect(readFileSync(join(dir, "dom.html"), "utf8")).toContain("injected");
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
test("appends (not overwrites) across invocations", async () => {
|
|
618
|
-
expectSemantic(fakePage(), "first", {
|
|
619
|
-
repoRoot: tmp,
|
|
620
|
-
specPath: "tests/a.spec.ts",
|
|
621
|
-
runId: "r1",
|
|
622
|
-
});
|
|
623
|
-
expectSemantic(fakePage(), "second", {
|
|
624
|
-
repoRoot: tmp,
|
|
625
|
-
specPath: "tests/a.spec.ts",
|
|
626
|
-
runId: "r1",
|
|
627
|
-
});
|
|
628
|
-
const queue = join(tmp, ".mandu", "ate-oracle-queue.jsonl");
|
|
629
|
-
const lines = readFileSync(queue, "utf8").trim().split("\n");
|
|
630
|
-
expect(lines.length).toBe(2);
|
|
631
|
-
});
|
|
632
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Phase C.1 — tests for the Mandu-specific assertion primitives.
|
|
3
|
+
*
|
|
4
|
+
* Each primitive has 6-8 cases per spec §C.1. They keep the entire suite
|
|
5
|
+
* hermetic — no network, no Playwright process, no real streaming server.
|
|
6
|
+
* Tests that need a page/response supply minimal mocks of the
|
|
7
|
+
* `Page`/`Response` shape each primitive consumes.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { join, dirname } from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import {
|
|
15
|
+
expectContract,
|
|
16
|
+
expectNavigation,
|
|
17
|
+
waitForIsland,
|
|
18
|
+
assertStreamBoundary,
|
|
19
|
+
expectSemantic,
|
|
20
|
+
ContractAssertionError,
|
|
21
|
+
NavigationAssertionError,
|
|
22
|
+
HydrationTimeoutError,
|
|
23
|
+
StreamBoundaryError,
|
|
24
|
+
SemanticDivergenceError,
|
|
25
|
+
type PlaywrightLikePage,
|
|
26
|
+
type PlaywrightIslandPage,
|
|
27
|
+
type ExpectSemanticPage,
|
|
28
|
+
type OracleQueueEntry,
|
|
29
|
+
} from "../assertions";
|
|
30
|
+
|
|
31
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// expectContract
|
|
33
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe("expectContract", () => {
|
|
36
|
+
const Signup = z.object({
|
|
37
|
+
userId: z.string().uuid(),
|
|
38
|
+
email: z.string().email(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("happy path — valid payload passes strict", () => {
|
|
42
|
+
const r = expectContract(
|
|
43
|
+
{ userId: "550e8400-e29b-41d4-a716-446655440000", email: "a@b.com" },
|
|
44
|
+
Signup,
|
|
45
|
+
);
|
|
46
|
+
expect(r.status).toBe("pass");
|
|
47
|
+
expect(r.violations.length).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("strict fails when a required field is missing", () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
expectContract({ email: "a@b.com" }, Signup),
|
|
53
|
+
).toThrow(ContractAssertionError);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("loose tolerates extra keys", () => {
|
|
57
|
+
const Open = Signup.passthrough();
|
|
58
|
+
const r = expectContract(
|
|
59
|
+
{ userId: "550e8400-e29b-41d4-a716-446655440000", email: "a@b.com", extra: 1 },
|
|
60
|
+
Open,
|
|
61
|
+
{ mode: "loose" },
|
|
62
|
+
);
|
|
63
|
+
expect(r.status).toBe("pass");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("drift-tolerant returns warnings without throwing", () => {
|
|
67
|
+
const r = expectContract(
|
|
68
|
+
{ userId: "not-a-uuid", email: "bad" },
|
|
69
|
+
Signup,
|
|
70
|
+
{ mode: "drift-tolerant" },
|
|
71
|
+
);
|
|
72
|
+
expect(r.status).toBe("fail");
|
|
73
|
+
expect(r.violations.every((v) => v.severity === "warning")).toBe(true);
|
|
74
|
+
expect(r.violations.length).toBeGreaterThan(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("ignorePaths skips the listed paths", () => {
|
|
78
|
+
// email violation is ignored; only userId would remain.
|
|
79
|
+
expect(() =>
|
|
80
|
+
expectContract({ userId: "not", email: "bad" }, Signup, {
|
|
81
|
+
ignorePaths: [".userId"],
|
|
82
|
+
}),
|
|
83
|
+
).toThrow(ContractAssertionError);
|
|
84
|
+
// But if we ignore both, it passes in loose mode (strict still flags extras only).
|
|
85
|
+
const r = expectContract(
|
|
86
|
+
{ userId: "550e8400-e29b-41d4-a716-446655440000", email: "a@b.com" },
|
|
87
|
+
Signup,
|
|
88
|
+
{ ignorePaths: [".userId", ".email"] },
|
|
89
|
+
);
|
|
90
|
+
expect(r.status).toBe("pass");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("strict flags extra keys in the actual payload", () => {
|
|
94
|
+
// Zod default strips unknown keys — expectContract surfaces them as
|
|
95
|
+
// strict-mode violations by walking actual vs parsed.
|
|
96
|
+
expect(() =>
|
|
97
|
+
expectContract(
|
|
98
|
+
{ userId: "550e8400-e29b-41d4-a716-446655440000", email: "a@b.com", extra: 1 },
|
|
99
|
+
Signup,
|
|
100
|
+
{ mode: "strict" },
|
|
101
|
+
),
|
|
102
|
+
).toThrow(/extra key/i);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("violations carry machine-readable paths", () => {
|
|
106
|
+
try {
|
|
107
|
+
expectContract({ userId: "bad", email: "a@b.com" }, Signup);
|
|
108
|
+
throw new Error("should have thrown");
|
|
109
|
+
} catch (err) {
|
|
110
|
+
expect(err).toBeInstanceOf(ContractAssertionError);
|
|
111
|
+
const ce = err as ContractAssertionError;
|
|
112
|
+
expect(ce.violations[0].path).toBe(".userId");
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
// expectNavigation
|
|
119
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
interface MockPage extends PlaywrightLikePage {
|
|
122
|
+
__pushUrl(u: string): void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function makeMockPage(initial: string): MockPage {
|
|
126
|
+
let current = initial;
|
|
127
|
+
const listeners: Array<(frame: { url: () => string }) => void> = [];
|
|
128
|
+
const page: MockPage = {
|
|
129
|
+
url: () => current,
|
|
130
|
+
on(event, listener) {
|
|
131
|
+
if (event === "framenavigated") listeners.push(listener as typeof listeners[number]);
|
|
132
|
+
},
|
|
133
|
+
off(event, listener) {
|
|
134
|
+
if (event !== "framenavigated") return;
|
|
135
|
+
const idx = listeners.indexOf(listener as typeof listeners[number]);
|
|
136
|
+
if (idx >= 0) listeners.splice(idx, 1);
|
|
137
|
+
},
|
|
138
|
+
async waitForURL(pattern) {
|
|
139
|
+
const deadline = Date.now() + 1000;
|
|
140
|
+
while (Date.now() < deadline) {
|
|
141
|
+
if (pattern instanceof RegExp ? pattern.test(current) : current === pattern || current.includes(String(pattern))) return;
|
|
142
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
__pushUrl(u) {
|
|
146
|
+
current = u;
|
|
147
|
+
for (const l of listeners) l({ url: () => u });
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
return page;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe("expectNavigation", () => {
|
|
154
|
+
test("passes when final URL matches regex", async () => {
|
|
155
|
+
const page = makeMockPage("http://localhost/");
|
|
156
|
+
setTimeout(() => page.__pushUrl("http://localhost/kr"), 5);
|
|
157
|
+
const r = await expectNavigation(page, { from: "http://localhost/", to: /\/kr(\/|$)/ });
|
|
158
|
+
expect(r.status).toBe("pass");
|
|
159
|
+
expect(r.chain.some((u) => u.endsWith("/kr"))).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("passes exact redirect count", async () => {
|
|
163
|
+
const page = makeMockPage("http://localhost/");
|
|
164
|
+
setTimeout(() => page.__pushUrl("http://localhost/kr"), 5);
|
|
165
|
+
setTimeout(() => page.__pushUrl("http://localhost/kr/home"), 10);
|
|
166
|
+
const r = await expectNavigation(page, {
|
|
167
|
+
from: "http://localhost/",
|
|
168
|
+
to: /\/kr\/home/,
|
|
169
|
+
redirectCount: 2,
|
|
170
|
+
timeoutMs: 200,
|
|
171
|
+
});
|
|
172
|
+
expect(r.status).toBe("pass");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("fails when final URL mismatches", async () => {
|
|
176
|
+
const page = makeMockPage("http://localhost/");
|
|
177
|
+
let threw = false;
|
|
178
|
+
try {
|
|
179
|
+
await expectNavigation(page, { to: /\/never-matches/, timeoutMs: 50 });
|
|
180
|
+
} catch (err) {
|
|
181
|
+
threw = true;
|
|
182
|
+
expect(err).toBeInstanceOf(NavigationAssertionError);
|
|
183
|
+
}
|
|
184
|
+
expect(threw).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("captures chain for later diagnosis", async () => {
|
|
188
|
+
const page = makeMockPage("http://localhost/");
|
|
189
|
+
setTimeout(() => page.__pushUrl("http://localhost/a"), 5);
|
|
190
|
+
setTimeout(() => page.__pushUrl("http://localhost/b"), 10);
|
|
191
|
+
try {
|
|
192
|
+
await expectNavigation(page, { to: /\/NEVER/, timeoutMs: 50 });
|
|
193
|
+
throw new Error("unreachable");
|
|
194
|
+
} catch (err) {
|
|
195
|
+
expect(err).toBeInstanceOf(NavigationAssertionError);
|
|
196
|
+
const ne = err as NavigationAssertionError;
|
|
197
|
+
expect(ne.detail.chain.length).toBeGreaterThanOrEqual(2);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("fails when redirectCount mismatches", async () => {
|
|
202
|
+
const page = makeMockPage("http://localhost/");
|
|
203
|
+
setTimeout(() => page.__pushUrl("http://localhost/x"), 5);
|
|
204
|
+
setTimeout(() => page.__pushUrl("http://localhost/y"), 10);
|
|
205
|
+
let threw = false;
|
|
206
|
+
try {
|
|
207
|
+
await expectNavigation(page, {
|
|
208
|
+
from: "http://localhost/",
|
|
209
|
+
to: /\/y/,
|
|
210
|
+
redirectCount: 1,
|
|
211
|
+
timeoutMs: 200,
|
|
212
|
+
});
|
|
213
|
+
} catch (err) {
|
|
214
|
+
threw = true;
|
|
215
|
+
expect(err).toBeInstanceOf(NavigationAssertionError);
|
|
216
|
+
}
|
|
217
|
+
expect(threw).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("enforces maxRedirects ≤", async () => {
|
|
221
|
+
const page = makeMockPage("http://localhost/");
|
|
222
|
+
setTimeout(() => page.__pushUrl("http://localhost/a"), 5);
|
|
223
|
+
setTimeout(() => page.__pushUrl("http://localhost/b"), 10);
|
|
224
|
+
setTimeout(() => page.__pushUrl("http://localhost/c"), 15);
|
|
225
|
+
let threw = false;
|
|
226
|
+
try {
|
|
227
|
+
await expectNavigation(page, {
|
|
228
|
+
from: "http://localhost/",
|
|
229
|
+
to: /\/c/,
|
|
230
|
+
maxRedirects: 1,
|
|
231
|
+
timeoutMs: 200,
|
|
232
|
+
});
|
|
233
|
+
} catch (err) {
|
|
234
|
+
threw = true;
|
|
235
|
+
}
|
|
236
|
+
expect(threw).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
241
|
+
// waitForIsland
|
|
242
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
function makeIslandPage(state: { strategy?: string; hydrated?: boolean; mounted?: boolean; delayMs?: number }): PlaywrightIslandPage & {
|
|
245
|
+
hydrate: () => void;
|
|
246
|
+
} {
|
|
247
|
+
const s = { mounted: true, hydrated: false, ...state };
|
|
248
|
+
let hydrateTick = 0;
|
|
249
|
+
return {
|
|
250
|
+
async evaluate<T, A>(fn: (arg: A) => T, arg: A): Promise<T> {
|
|
251
|
+
// We don't actually run `fn` in a browser — we inspect the shape
|
|
252
|
+
// of `arg` and answer based on our mock state. The primitive's
|
|
253
|
+
// strategy-sniffing evaluate call passes a bare `name` (string);
|
|
254
|
+
// the polling evaluate call passes `{ name, state }`.
|
|
255
|
+
hydrateTick++;
|
|
256
|
+
if (state.delayMs && hydrateTick > 2) s.hydrated = true;
|
|
257
|
+
if (typeof arg === "string") {
|
|
258
|
+
// strategy sniff
|
|
259
|
+
return (s.strategy ?? null) as unknown as T;
|
|
260
|
+
}
|
|
261
|
+
if (!s.mounted) return { mounted: false, hydrated: false } as unknown as T;
|
|
262
|
+
if ((arg as { state: string }).state === "visible") return { mounted: true, hydrated: true } as unknown as T;
|
|
263
|
+
return { mounted: s.mounted, hydrated: s.hydrated } as unknown as T;
|
|
264
|
+
},
|
|
265
|
+
hydrate() {
|
|
266
|
+
s.hydrated = true;
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
describe("waitForIsland", () => {
|
|
272
|
+
test("resolves immediately for hydration:none strategy", async () => {
|
|
273
|
+
const page = makeIslandPage({ strategy: "none" });
|
|
274
|
+
const before = Date.now();
|
|
275
|
+
await waitForIsland(page, "Legal", { timeoutMs: 200 });
|
|
276
|
+
expect(Date.now() - before).toBeLessThan(200);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("resolves when data-hydrated flips true", async () => {
|
|
280
|
+
const page = makeIslandPage({ strategy: "visible", delayMs: 50 });
|
|
281
|
+
await waitForIsland(page, "Cart", { timeoutMs: 500 });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("throws HydrationTimeoutError after timeout", async () => {
|
|
285
|
+
const page = makeIslandPage({ strategy: "visible", hydrated: false });
|
|
286
|
+
let threw = false;
|
|
287
|
+
try {
|
|
288
|
+
await waitForIsland(page, "NeverHydrates", { timeoutMs: 100 });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
threw = true;
|
|
291
|
+
expect(err).toBeInstanceOf(HydrationTimeoutError);
|
|
292
|
+
expect((err as HydrationTimeoutError).detail.island).toBe("NeverHydrates");
|
|
293
|
+
}
|
|
294
|
+
expect(threw).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("state:visible passes once the island mounts", async () => {
|
|
298
|
+
const page = makeIslandPage({ strategy: "visible" });
|
|
299
|
+
await waitForIsland(page, "Banner", { timeoutMs: 100, state: "visible" });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("evaluate exception retries the poll", async () => {
|
|
303
|
+
let calls = 0;
|
|
304
|
+
const page: PlaywrightIslandPage = {
|
|
305
|
+
async evaluate<T, A>(_fn: (arg: A) => T, arg: A): Promise<T> {
|
|
306
|
+
calls++;
|
|
307
|
+
if (calls === 1) throw new Error("transient");
|
|
308
|
+
if (typeof arg === "string") return "visible" as unknown as T;
|
|
309
|
+
return { mounted: true, hydrated: true } as unknown as T;
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
await waitForIsland(page, "Retry", { timeoutMs: 500 });
|
|
313
|
+
expect(calls).toBeGreaterThanOrEqual(2);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("detail payload has failure.v1 shape", async () => {
|
|
317
|
+
const page = makeIslandPage({ strategy: "visible", hydrated: false });
|
|
318
|
+
try {
|
|
319
|
+
await waitForIsland(page, "X", { timeoutMs: 30 });
|
|
320
|
+
throw new Error("unreachable");
|
|
321
|
+
} catch (err) {
|
|
322
|
+
const he = err as HydrationTimeoutError;
|
|
323
|
+
expect(he.kind).toBe("hydration_timeout");
|
|
324
|
+
expect(typeof he.detail.waitedMs).toBe("number");
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
330
|
+
// assertStreamBoundary
|
|
331
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
function makeStreamResponse(chunks: string[]): Response {
|
|
334
|
+
const encoder = new TextEncoder();
|
|
335
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
336
|
+
async start(controller) {
|
|
337
|
+
for (const c of chunks) {
|
|
338
|
+
controller.enqueue(encoder.encode(c));
|
|
339
|
+
}
|
|
340
|
+
controller.close();
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
return new Response(stream, { headers: { "Content-Type": "text/html" } });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
describe("assertStreamBoundary", () => {
|
|
347
|
+
test("passes when shell and tail match", async () => {
|
|
348
|
+
const res = makeStreamResponse([
|
|
349
|
+
"<!DOCTYPE html><html><head></head><body>shell",
|
|
350
|
+
"<!--$--><div>slow</div><!--/$--><script>islands</script>",
|
|
351
|
+
]);
|
|
352
|
+
const r = await assertStreamBoundary(res, {
|
|
353
|
+
shellChunkContains: ["<!DOCTYPE", "<html"],
|
|
354
|
+
boundaryCount: 1,
|
|
355
|
+
tailChunkContainsAnyOf: ["<script"],
|
|
356
|
+
});
|
|
357
|
+
expect(r.status).toBe("pass");
|
|
358
|
+
expect(r.boundaryOpenCount).toBe(1);
|
|
359
|
+
expect(r.boundaryCloseCount).toBe(1);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("counts multiple boundaries", async () => {
|
|
363
|
+
const res = makeStreamResponse([
|
|
364
|
+
"<html>",
|
|
365
|
+
"<!--$-->a<!--/$-->",
|
|
366
|
+
"<!--$-->b<!--/$-->",
|
|
367
|
+
"<!--$-->c<!--/$-->",
|
|
368
|
+
]);
|
|
369
|
+
const r = await assertStreamBoundary(res, { boundaryCount: 3 });
|
|
370
|
+
expect(r.boundaryOpenCount).toBe(3);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("fails when boundaryCount mismatches", async () => {
|
|
374
|
+
const res = makeStreamResponse(["<html><!--$-->x<!--/$-->"]);
|
|
375
|
+
let threw = false;
|
|
376
|
+
try {
|
|
377
|
+
await assertStreamBoundary(res, { boundaryCount: 2 });
|
|
378
|
+
} catch (err) {
|
|
379
|
+
threw = true;
|
|
380
|
+
expect(err).toBeInstanceOf(StreamBoundaryError);
|
|
381
|
+
}
|
|
382
|
+
expect(threw).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("fails when shell missing expected marker", async () => {
|
|
386
|
+
const res = makeStreamResponse(["<html>body only"]);
|
|
387
|
+
let threw = false;
|
|
388
|
+
try {
|
|
389
|
+
await assertStreamBoundary(res, { shellChunkContains: ["<!DOCTYPE"] });
|
|
390
|
+
} catch (err) {
|
|
391
|
+
threw = true;
|
|
392
|
+
expect(err).toBeInstanceOf(StreamBoundaryError);
|
|
393
|
+
}
|
|
394
|
+
expect(threw).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("fails when shell exceeds byte budget", async () => {
|
|
398
|
+
const big = "<html>" + "x".repeat(5_000);
|
|
399
|
+
const res = makeStreamResponse([big]);
|
|
400
|
+
let threw = false;
|
|
401
|
+
try {
|
|
402
|
+
await assertStreamBoundary(res, { firstChunkMaxSizeBytes: 1_000 });
|
|
403
|
+
} catch (err) {
|
|
404
|
+
threw = true;
|
|
405
|
+
expect(err).toBeInstanceOf(StreamBoundaryError);
|
|
406
|
+
}
|
|
407
|
+
expect(threw).toBe(true);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("fails when tail misses all candidates", async () => {
|
|
411
|
+
const res = makeStreamResponse(["<html>", "end without script"]);
|
|
412
|
+
let threw = false;
|
|
413
|
+
try {
|
|
414
|
+
await assertStreamBoundary(res, { tailChunkContainsAnyOf: ["<script", "island"] });
|
|
415
|
+
} catch (err) {
|
|
416
|
+
threw = true;
|
|
417
|
+
}
|
|
418
|
+
expect(threw).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("throws on empty body", async () => {
|
|
422
|
+
const res = new Response(null);
|
|
423
|
+
let threw = false;
|
|
424
|
+
try {
|
|
425
|
+
await assertStreamBoundary(res, {});
|
|
426
|
+
} catch {
|
|
427
|
+
threw = true;
|
|
428
|
+
}
|
|
429
|
+
expect(threw).toBe(true);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("passes with no expectations (sanity)", async () => {
|
|
433
|
+
const res = makeStreamResponse(["<html>"]);
|
|
434
|
+
const r = await assertStreamBoundary(res, {});
|
|
435
|
+
expect(r.status).toBe("pass");
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
440
|
+
// expectSemantic
|
|
441
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
describe("expectSemantic", () => {
|
|
444
|
+
let tmp: string;
|
|
445
|
+
let priorDetOnly: string | undefined;
|
|
446
|
+
|
|
447
|
+
beforeEach(() => {
|
|
448
|
+
tmp = mkdtempSync(join(tmpdir(), "mandu-expect-semantic-"));
|
|
449
|
+
priorDetOnly = process.env.MANDU_ATE_DETERMINISTIC_ONLY;
|
|
450
|
+
delete process.env.MANDU_ATE_DETERMINISTIC_ONLY;
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
afterEach(() => {
|
|
454
|
+
if (priorDetOnly === undefined) {
|
|
455
|
+
delete process.env.MANDU_ATE_DETERMINISTIC_ONLY;
|
|
456
|
+
} else {
|
|
457
|
+
process.env.MANDU_ATE_DETERMINISTIC_ONLY = priorDetOnly;
|
|
458
|
+
}
|
|
459
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
function fakePage(): ExpectSemanticPage {
|
|
463
|
+
return {
|
|
464
|
+
async content() {
|
|
465
|
+
return "<html><body>hi</body></html>";
|
|
466
|
+
},
|
|
467
|
+
async screenshot() {
|
|
468
|
+
return new Uint8Array([1, 2, 3]);
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
test("enqueues a pending entry with pass status", async () => {
|
|
474
|
+
const page = fakePage();
|
|
475
|
+
const r = expectSemantic(page, "user sees error near field", {
|
|
476
|
+
repoRoot: tmp,
|
|
477
|
+
specPath: "tests/demo.spec.ts",
|
|
478
|
+
runId: "run-1",
|
|
479
|
+
});
|
|
480
|
+
expect(r.status).toBe("pass");
|
|
481
|
+
expect(r.deferred).toBe(true);
|
|
482
|
+
|
|
483
|
+
// Give the microtask a moment to flush artifacts.
|
|
484
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
485
|
+
|
|
486
|
+
const queue = join(tmp, ".mandu", "ate-oracle-queue.jsonl");
|
|
487
|
+
expect(existsSync(queue)).toBe(true);
|
|
488
|
+
const line = readFileSync(queue, "utf8").trim();
|
|
489
|
+
const entry: OracleQueueEntry = JSON.parse(line);
|
|
490
|
+
expect(entry.status).toBe("pending");
|
|
491
|
+
expect(entry.claim).toContain("error");
|
|
492
|
+
expect(entry.assertionId).toBe(r.assertionId);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("CI DETERMINISTIC_ONLY skips file writes entirely", () => {
|
|
496
|
+
process.env.MANDU_ATE_DETERMINISTIC_ONLY = "1";
|
|
497
|
+
const page = fakePage();
|
|
498
|
+
const r = expectSemantic(page, "non-blocking claim", {
|
|
499
|
+
repoRoot: tmp,
|
|
500
|
+
specPath: "tests/ci.spec.ts",
|
|
501
|
+
});
|
|
502
|
+
expect(r.status).toBe("pass");
|
|
503
|
+
expect(r.deferred).toBe(true);
|
|
504
|
+
const queue = join(tmp, ".mandu", "ate-oracle-queue.jsonl");
|
|
505
|
+
expect(existsSync(queue)).toBe(false);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("assertionId is stable across calls with same (claim, specPath)", () => {
|
|
509
|
+
const page = fakePage();
|
|
510
|
+
const a = expectSemantic(page, "same claim", { repoRoot: tmp, specPath: "x.spec.ts", runId: "1" });
|
|
511
|
+
const b = expectSemantic(page, "same claim", { repoRoot: tmp, specPath: "x.spec.ts", runId: "2" });
|
|
512
|
+
expect(a.assertionId).toBe(b.assertionId);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("promoteVerdicts promotes past failed verdicts to runtime fail", async () => {
|
|
516
|
+
// Manually seed a past `failed` verdict with a matching assertionId.
|
|
517
|
+
const claim = "promote this one";
|
|
518
|
+
const specPath = "tests/promote.spec.ts";
|
|
519
|
+
// Use expectSemantic to generate the id deterministically, then
|
|
520
|
+
// write a synthetic `failed` entry before invoking again.
|
|
521
|
+
const first = expectSemantic(fakePage(), claim, {
|
|
522
|
+
repoRoot: tmp,
|
|
523
|
+
specPath,
|
|
524
|
+
runId: "r1",
|
|
525
|
+
});
|
|
526
|
+
const queuePath = join(tmp, ".mandu", "ate-oracle-queue.jsonl");
|
|
527
|
+
mkdirSync(dirname(queuePath), { recursive: true });
|
|
528
|
+
const failed: OracleQueueEntry = {
|
|
529
|
+
assertionId: first.assertionId,
|
|
530
|
+
specPath,
|
|
531
|
+
runId: "r1",
|
|
532
|
+
claim,
|
|
533
|
+
artifactPath: tmp,
|
|
534
|
+
status: "failed",
|
|
535
|
+
verdict: { judgedBy: "agent", reason: "missing error color", timestamp: new Date().toISOString() },
|
|
536
|
+
timestamp: new Date().toISOString(),
|
|
537
|
+
};
|
|
538
|
+
writeFileSync(queuePath, `${JSON.stringify(failed)}\n`);
|
|
539
|
+
|
|
540
|
+
// Now call with promoteVerdicts — should throw.
|
|
541
|
+
let threw = false;
|
|
542
|
+
try {
|
|
543
|
+
expectSemantic(fakePage(), claim, {
|
|
544
|
+
repoRoot: tmp,
|
|
545
|
+
specPath,
|
|
546
|
+
promoteVerdicts: true,
|
|
547
|
+
});
|
|
548
|
+
} catch (err) {
|
|
549
|
+
threw = true;
|
|
550
|
+
expect(err).toBeInstanceOf(SemanticDivergenceError);
|
|
551
|
+
}
|
|
552
|
+
expect(threw).toBe(true);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("promoteVerdicts ignores pending + passed entries", () => {
|
|
556
|
+
const claim = "still open";
|
|
557
|
+
const specPath = "tests/pend.spec.ts";
|
|
558
|
+
const first = expectSemantic(fakePage(), claim, {
|
|
559
|
+
repoRoot: tmp,
|
|
560
|
+
specPath,
|
|
561
|
+
runId: "r1",
|
|
562
|
+
});
|
|
563
|
+
const queuePath = join(tmp, ".mandu", "ate-oracle-queue.jsonl");
|
|
564
|
+
const passed: OracleQueueEntry = {
|
|
565
|
+
assertionId: first.assertionId,
|
|
566
|
+
specPath,
|
|
567
|
+
runId: "r1",
|
|
568
|
+
claim,
|
|
569
|
+
artifactPath: tmp,
|
|
570
|
+
status: "passed",
|
|
571
|
+
verdict: { judgedBy: "agent", reason: "looks good", timestamp: new Date().toISOString() },
|
|
572
|
+
timestamp: new Date().toISOString(),
|
|
573
|
+
};
|
|
574
|
+
writeFileSync(queuePath, `${JSON.stringify(passed)}\n`);
|
|
575
|
+
|
|
576
|
+
// No throw — still pass.
|
|
577
|
+
const r = expectSemantic(fakePage(), claim, {
|
|
578
|
+
repoRoot: tmp,
|
|
579
|
+
specPath,
|
|
580
|
+
promoteVerdicts: true,
|
|
581
|
+
});
|
|
582
|
+
expect(r.status).toBe("pass");
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("capture:dom only writes dom.html", async () => {
|
|
586
|
+
const page = fakePage();
|
|
587
|
+
const r = expectSemantic(page, "dom only", {
|
|
588
|
+
repoRoot: tmp,
|
|
589
|
+
specPath: "tests/d.spec.ts",
|
|
590
|
+
runId: "run-dom",
|
|
591
|
+
capture: "dom",
|
|
592
|
+
});
|
|
593
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
594
|
+
const artifactDir = join(tmp, ".mandu", "ate-oracle-queue", "run-dom", r.assertionId);
|
|
595
|
+
expect(existsSync(join(artifactDir, "dom.html"))).toBe(true);
|
|
596
|
+
expect(existsSync(join(artifactDir, "screenshot.png"))).toBe(false);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("explicit injected snapshots bypass page calls", async () => {
|
|
600
|
+
// Page has no methods — we provide explicit artifacts.
|
|
601
|
+
const r = expectSemantic(
|
|
602
|
+
{},
|
|
603
|
+
"injected",
|
|
604
|
+
{
|
|
605
|
+
repoRoot: tmp,
|
|
606
|
+
specPath: "tests/inj.spec.ts",
|
|
607
|
+
runId: "inj-1",
|
|
608
|
+
domSnapshot: "<html><body>injected</body></html>",
|
|
609
|
+
screenshotBytes: new Uint8Array([9, 9]),
|
|
610
|
+
},
|
|
611
|
+
);
|
|
612
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
613
|
+
const dir = join(tmp, ".mandu", "ate-oracle-queue", "inj-1", r.assertionId);
|
|
614
|
+
expect(readFileSync(join(dir, "dom.html"), "utf8")).toContain("injected");
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("appends (not overwrites) across invocations", async () => {
|
|
618
|
+
expectSemantic(fakePage(), "first", {
|
|
619
|
+
repoRoot: tmp,
|
|
620
|
+
specPath: "tests/a.spec.ts",
|
|
621
|
+
runId: "r1",
|
|
622
|
+
});
|
|
623
|
+
expectSemantic(fakePage(), "second", {
|
|
624
|
+
repoRoot: tmp,
|
|
625
|
+
specPath: "tests/a.spec.ts",
|
|
626
|
+
runId: "r1",
|
|
627
|
+
});
|
|
628
|
+
const queue = join(tmp, ".mandu", "ate-oracle-queue.jsonl");
|
|
629
|
+
const lines = readFileSync(queue, "utf8").trim().split("\n");
|
|
630
|
+
expect(lines.length).toBe(2);
|
|
631
|
+
});
|
|
632
|
+
});
|