From 0cf5ccde4ebe9b38ef3fc11e109307a261fc5fbb Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 2 Aug 2026 04:37:35 +0900 Subject: [PATCH 1/9] feat: provide deterministic test runtime controls --- .../deterministic-test-runtime-controls.md | 8 + docs/problem-code-registry.json | 72 ++++- .../en/reference/problem-recovery-cookbook.md | 50 +++- .../src/generated/problem-code-registry.ts | 76 ++++- .../src/libs/InMemoryRateLimitStore.ts | 20 +- .../ratelimit-core/src/libs/RateLimitStore.ts | 39 +-- packages/tasks-core/src/index.ts | 2 +- packages/tasks-core/src/libs/TaskRunner.ts | 38 ++- .../tasks-core/src/tests/TaskRunner.spec.ts | 50 ++++ packages/testing/README.md | 45 ++- packages/testing/package.json | 1 + packages/testing/src/index.ts | 20 ++ packages/testing/src/libs/TestKernel.ts | 170 +++++++++-- packages/testing/src/libs/TestRuntime.ts | 269 ++++++++++++++++++ packages/testing/src/tests/TestKernel.spec.ts | 153 +++++++++- .../testing/src/tests/TestRuntime.spec.ts | 119 ++++++++ packages/testing/tsconfig.json | 1 + pnpm-lock.yaml | 3 + public-api-surface.snapshot.json | 105 +++++++ 19 files changed, 1155 insertions(+), 86 deletions(-) create mode 100644 .changeset/deterministic-test-runtime-controls.md create mode 100644 packages/testing/src/libs/TestRuntime.ts create mode 100644 packages/testing/src/tests/TestRuntime.spec.ts diff --git a/.changeset/deterministic-test-runtime-controls.md b/.changeset/deterministic-test-runtime-controls.md new file mode 100644 index 000000000..f11d470de --- /dev/null +++ b/.changeset/deterministic-test-runtime-controls.md @@ -0,0 +1,8 @@ +--- +"@croco/ratelimit-core": minor +"@croco/tasks-core": minor +"@croco/testing": minor +"@croco/problems-core": minor +--- + +Provide scoped TestKernel virtual time, seeded IDs and random values, environment snapshots, outbound-call denial, replay metadata, and deterministic leak diagnostics. Retry backoff, task timeouts, and in-memory rate-limit stores can now consume injected time and randomness without global timer or random patches. diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json index 94f1dddfd..eaf15bfeb 100644 --- a/docs/problem-code-registry.json +++ b/docs/problem-code-registry.json @@ -14636,7 +14636,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 144, + "line": 184, "column": 5, "kind": "problem-constructor" } @@ -14666,7 +14666,67 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 172, + "line": 212, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "testing/test-kernel-leak", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#testing-test-kernel-leak", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "lifecycle": { + "status": "active" + }, + "sources": [ + { + "file": "packages/testing/src/libs/TestKernel.ts", + "line": 222, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "testing/test-kernel-outbound-call", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#testing-test-kernel-outbound-call", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "lifecycle": { + "status": "active" + }, + "sources": [ + { + "file": "packages/testing/src/libs/TestRuntime.ts", + "line": 41, "column": 5, "kind": "problem-constructor" } @@ -14696,7 +14756,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 182, + "line": 233, "column": 5, "kind": "problem-constructor" } @@ -14726,7 +14786,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 198, + "line": 249, "column": 5, "kind": "problem-constructor" } @@ -14756,7 +14816,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 211, + "line": 262, "column": 5, "kind": "problem-constructor" } @@ -14786,7 +14846,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 131, + "line": 171, "column": 5, "kind": "problem-constructor" } diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md index a22e09f15..5f221855f 100644 --- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md +++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md @@ -502,6 +502,8 @@ This cookbook documents 571 public Croco Problem codes. The deterministic JSON r | [`testing/telemetry-provider-already-installed`](#testing-telemetry-provider-already-installed) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`testing/test-kernel-disposal-failed`](#testing-test-kernel-disposal-failed) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`testing/test-kernel-disposed`](#testing-test-kernel-disposed) | InternalServerError | 500 | conditional | operator-only | active | 1 | +| [`testing/test-kernel-leak`](#testing-test-kernel-leak) | InternalServerError | 500 | conditional | operator-only | active | 1 | +| [`testing/test-kernel-outbound-call`](#testing-test-kernel-outbound-call) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`testing/test-kernel-resource-fidelity`](#testing-test-kernel-resource-fidelity) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`testing/test-kernel-resource-not-found`](#testing-test-kernel-resource-not-found) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`testing/test-kernel-resource-registration`](#testing-test-kernel-resource-registration) | InternalServerError | 500 | conditional | operator-only | active | 1 | @@ -9369,7 +9371,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:144:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:184:5` (problem-constructor) @@ -9387,7 +9389,43 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:172:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:212:5` (problem-constructor) + + + +## `testing/test-kernel-leak` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Lifecycle: `active` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/testing/src/libs/TestKernel.ts:222:5` (problem-constructor) + + + +## `testing/test-kernel-outbound-call` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Lifecycle: `active` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/testing/src/libs/TestRuntime.ts:41:5` (problem-constructor) @@ -9405,7 +9443,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:182:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:233:5` (problem-constructor) @@ -9423,7 +9461,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:198:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:249:5` (problem-constructor) @@ -9441,7 +9479,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:211:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:262:5` (problem-constructor) @@ -9459,7 +9497,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:131:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:171:5` (problem-constructor) diff --git a/packages/problems-core/src/generated/problem-code-registry.ts b/packages/problems-core/src/generated/problem-code-registry.ts index f354fc14f..d3d069db4 100644 --- a/packages/problems-core/src/generated/problem-code-registry.ts +++ b/packages/problems-core/src/generated/problem-code-registry.ts @@ -15253,7 +15253,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 144, + line: 184, column: 5, kind: "problem-constructor", }, @@ -15285,7 +15285,71 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 172, + line: 212, + column: 5, + kind: "problem-constructor", + }, + ], + }, + { + code: "testing/test-kernel-leak", + category: "InternalServerError", + status: 500, + title: "Internal Server Error", + cookbookPath: "/reference/problem-recovery-cookbook/#testing-test-kernel-leak", + recovery: { + cause: "Croco or an upstream dependency failed after accepting the request.", + userAction: + "Retry later only when the operation is idempotent or the caller owns retry safety.", + operatorAction: + "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + retryability: "conditional", + redactionPolicy: "operator-only", + telemetry: { + eventName: "croco.problem.error", + severity: "error", + attributes: ["problem.code", "problem.category", "problem.status"], + }, + }, + lifecycle: { + status: "active", + }, + sources: [ + { + file: "packages/testing/src/libs/TestKernel.ts", + line: 222, + column: 5, + kind: "problem-constructor", + }, + ], + }, + { + code: "testing/test-kernel-outbound-call", + category: "InternalServerError", + status: 500, + title: "Internal Server Error", + cookbookPath: "/reference/problem-recovery-cookbook/#testing-test-kernel-outbound-call", + recovery: { + cause: "Croco or an upstream dependency failed after accepting the request.", + userAction: + "Retry later only when the operation is idempotent or the caller owns retry safety.", + operatorAction: + "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + retryability: "conditional", + redactionPolicy: "operator-only", + telemetry: { + eventName: "croco.problem.error", + severity: "error", + attributes: ["problem.code", "problem.category", "problem.status"], + }, + }, + lifecycle: { + status: "active", + }, + sources: [ + { + file: "packages/testing/src/libs/TestRuntime.ts", + line: 41, column: 5, kind: "problem-constructor", }, @@ -15317,7 +15381,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 182, + line: 233, column: 5, kind: "problem-constructor", }, @@ -15349,7 +15413,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 198, + line: 249, column: 5, kind: "problem-constructor", }, @@ -15382,7 +15446,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 211, + line: 262, column: 5, kind: "problem-constructor", }, @@ -15414,7 +15478,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 131, + line: 171, column: 5, kind: "problem-constructor", }, diff --git a/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts b/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts index e2e365294..195b81f68 100644 --- a/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts +++ b/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts @@ -11,7 +11,9 @@ import type { import { RateLimitRefundUnsupportedProblem } from "./problems/RateLimitConfigProblems"; export type InMemoryRateLimitStoreOptions = { - pruneIntervalMs?: number; + readonly now?: () => number; + readonly pruneIntervalMs?: number; + readonly random?: () => number; }; const DEFAULT_PRUNE_INTERVAL_MS = 60000; @@ -36,7 +38,7 @@ export class FixedWindowInMemoryStore extends FixedWindowStore { private readonly pruneTimer?: ReturnType; constructor(options: InMemoryRateLimitStoreOptions = {}) { - super(); + super(options.now, options.random); const pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS; if (pruneIntervalMs > 0) { @@ -89,7 +91,7 @@ export class FixedWindowInMemoryStore extends FixedWindowStore { const entry = this.windows.get(key); if (!entry) return null; - const windowStart = Math.floor(Date.now() / policy.windowMs) * policy.windowMs; + const windowStart = Math.floor(this.now() / policy.windowMs) * policy.windowMs; if (entry.windowStart !== windowStart) { return null; } @@ -127,7 +129,7 @@ export class FixedWindowInMemoryStore extends FixedWindowStore { } async pruneExpired(): Promise { - const now = Date.now(); + const now = this.now(); let deletedCount = 0; for (const [key, entry] of this.windows.entries()) { @@ -157,7 +159,7 @@ export class SlidingWindowInMemoryStore extends SlidingWindowStore { private readonly pruneTimer?: ReturnType; constructor(options: InMemoryRateLimitStoreOptions = {}) { - super(); + super(options.now, options.random); const pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS; if (pruneIntervalMs > 0) { @@ -205,7 +207,7 @@ export class SlidingWindowInMemoryStore extends SlidingWindowStore { throw new RateLimitRefundUnsupportedProblem(); } - const now = Date.now(); + const now = this.now(); const windowStart = now - policy.windowMs; await this.removeTimestamps(key, windowStart); @@ -307,7 +309,7 @@ export class SlidingWindowInMemoryStore extends SlidingWindowStore { } async pruneExpired(): Promise { - const now = Date.now(); + const now = this.now(); let deletedCount = 0; for (const [key, entry] of this.windows.entries()) { @@ -341,7 +343,7 @@ export class TokenBucketInMemoryStore extends TokenBucketStore { private readonly pruneTimer?: ReturnType; constructor(options: InMemoryRateLimitStoreOptions = {}) { - super(); + super(options.now, options.random); const pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS; if (pruneIntervalMs > 0) { @@ -418,7 +420,7 @@ export class TokenBucketInMemoryStore extends TokenBucketStore { } async pruneExpired(): Promise { - const now = Date.now(); + const now = this.now(); let deletedCount = 0; for (const [key, entry] of this.buckets.entries()) { diff --git a/packages/ratelimit-core/src/libs/RateLimitStore.ts b/packages/ratelimit-core/src/libs/RateLimitStore.ts index d0b54516a..dcd528c6f 100644 --- a/packages/ratelimit-core/src/libs/RateLimitStore.ts +++ b/packages/ratelimit-core/src/libs/RateLimitStore.ts @@ -34,6 +34,13 @@ export type DistributedRateLimitStoreOptions = { }; export abstract class RateLimitStore { + private refundReceiptSequence = 0; + + protected constructor( + protected readonly now: () => number = () => Date.now(), + protected readonly random: () => number = Math.random, + ) {} + abstract check(key: string, policy: RateLimitPolicy): Promise; async refund( _key: string, @@ -44,6 +51,12 @@ export abstract class RateLimitStore { } abstract getStats(key?: string): Promise; abstract pruneExpired(): Promise; + + protected createRefundReceiptId(algorithm: string, timestamp: number): string { + this.refundReceiptSequence = (this.refundReceiptSequence + 1) % Number.MAX_SAFE_INTEGER; + const randomPart = this.random().toString(36).slice(2, 10); + return `${algorithm}:${timestamp}:${this.refundReceiptSequence}:${randomPart}`; + } } export abstract class DistributedRateLimitStore extends RateLimitStore { @@ -70,7 +83,7 @@ export abstract class FixedWindowStore extends DistributedRateLimitStore { ): Promise; async checkFixedWindow(key: string, policy: FixedWindowPolicy): Promise { - const now = Date.now(); + const now = this.now(); const windowStart = Math.floor(now / policy.windowMs) * policy.windowMs; const resetAtMs = windowStart + policy.windowMs; @@ -155,7 +168,7 @@ export abstract class FixedWindowStore extends DistributedRateLimitStore { ): FixedWindowRefundReceipt { const receipt: FixedWindowRefundReceipt = { algorithm: "fixed", - id: createRefundReceiptId("fixed", windowStart), + id: this.createRefundReceiptId("fixed", windowStart), windowStart, }; const current = this.refundReceipts.get(key); @@ -218,7 +231,7 @@ export abstract class SlidingWindowStore extends DistributedRateLimitStore { protected abstract removeTimestamps(key: string, before: number): Promise; async checkSlidingWindow(key: string, policy: SlidingWindowPolicy): Promise { - const now = Date.now(); + const now = this.now(); const windowStart = now - policy.windowMs; await this.removeTimestamps(key, windowStart); @@ -229,7 +242,7 @@ export abstract class SlidingWindowStore extends DistributedRateLimitStore { const refundReceipt: SlidingWindowRefundReceipt | undefined = success ? { algorithm: "sliding", - id: createRefundReceiptId("sliding", now), + id: this.createRefundReceiptId("sliding", now), timestamp: now, } : undefined; @@ -266,7 +279,7 @@ export abstract class TokenBucketStore extends DistributedRateLimitStore { protected abstract setBucket(key: string, entry: TokenBucketEntry, ttlMs: number): Promise; async checkTokenBucket(key: string, policy: TokenBucketPolicy): Promise { - const now = Date.now(); + const now = this.now(); const intervalMs = policy.refillIntervalMs; let bucket = await this.getBucket(key); @@ -333,7 +346,7 @@ export abstract class TokenBucketStore extends DistributedRateLimitStore { throw new RateLimitRefundUnsupportedProblem(); } - const now = Date.now(); + const now = this.now(); const intervalMs = policy.refillIntervalMs; const ttlMs = (policy.capacity * intervalMs) / policy.refillRate; const resetAtMs = now + intervalMs / policy.refillRate; @@ -391,7 +404,7 @@ export abstract class TokenBucketStore extends DistributedRateLimitStore { }; } - const now = Date.now(); + const now = this.now(); const tokensToAdd = Math.floor( ((now - bucket.lastRefill) / policy.refillIntervalMs) * policy.refillRate, ); @@ -415,12 +428,12 @@ export abstract class TokenBucketStore extends DistributedRateLimitStore { key: string, expiresAtMs: number, ): TokenBucketRefundReceipt { - const now = Date.now(); + const now = this.now(); this.pruneTokenBucketRefundReceipts(key, now); const receipt: TokenBucketRefundReceipt = { algorithm: "token-bucket", - id: createRefundReceiptId("token-bucket", now), + id: this.createRefundReceiptId("token-bucket", now), expiresAtMs, }; const receipts = this.refundReceipts.get(key) ?? new Map(); @@ -475,11 +488,3 @@ export abstract class TokenBucketStore extends DistributedRateLimitStore { } } } - -let refundReceiptSequence = 0; - -function createRefundReceiptId(algorithm: string, timestamp: number): string { - refundReceiptSequence = (refundReceiptSequence + 1) % Number.MAX_SAFE_INTEGER; - const randomPart = Math.random().toString(36).slice(2, 10); - return `${algorithm}:${timestamp}:${refundReceiptSequence}:${randomPart}`; -} diff --git a/packages/tasks-core/src/index.ts b/packages/tasks-core/src/index.ts index ee904bfcb..3235ff060 100644 --- a/packages/tasks-core/src/index.ts +++ b/packages/tasks-core/src/index.ts @@ -26,7 +26,7 @@ export { TaskRegistry } from "./libs/TaskRegistry"; /** * 등록된 태스크를 실행 시스템과 연결해 실행하는 러너입니다. */ -export { TaskRunner } from "./libs/TaskRunner"; +export { TaskRunner, type TaskRunnerRuntime } from "./libs/TaskRunner"; /** * 태스크 선언과 식별에 사용하는 공개 타입들입니다. diff --git a/packages/tasks-core/src/libs/TaskRunner.ts b/packages/tasks-core/src/libs/TaskRunner.ts index ea6d3a1bc..df2763c2f 100644 --- a/packages/tasks-core/src/libs/TaskRunner.ts +++ b/packages/tasks-core/src/libs/TaskRunner.ts @@ -14,6 +14,11 @@ type Constructor = new (...args: unknown[]) => T; const MAX_TIMER_DELAY_MS = 2_147_483_647; +export type TaskRunnerRuntime = { + readonly now?: () => number; + readonly schedule?: (callback: () => void, delayMs: number) => () => void; +}; + const noopLogger: ILogger = { debug: () => {}, info: () => {}, @@ -91,11 +96,23 @@ function recordDiagnosticError(error: unknown): void { } export class TaskRunner { + private readonly now: () => number; + private readonly schedule: (callback: () => void, delayMs: number) => () => void; + constructor( private executionManager: ExecutionManager, private registry: TaskRegistry = TaskRegistry.fromMetadata(), private logger: ILogger = noopLogger, - ) {} + runtime: TaskRunnerRuntime = {}, + ) { + this.now = runtime.now ?? (() => Date.now()); + this.schedule = + runtime.schedule ?? + ((callback, delayMs) => { + const timeout = setTimeout(callback, delayMs); + return () => clearTimeout(timeout); + }); + } async execute( taskId: string, @@ -166,7 +183,7 @@ export class TaskRunner { ? undefined : new TaskExecutionTimeoutProblem(startedExecution.id, timeoutMs); let timeoutClaimed = false; - let timeoutHandle: ReturnType | undefined; + let cancelTimeout: (() => void) | undefined; let triggerTimeout: (() => void) | undefined; const timeoutPromise = timeoutProblem === undefined || deadline === undefined @@ -182,20 +199,23 @@ export class TaskRunner { ); }; const scheduleTimeout = () => { - const remaining = deadline - Date.now(); + const remaining = deadline - this.now(); if (remaining <= 0) { triggerTimeout?.(); return; } - timeoutHandle = setTimeout(scheduleTimeout, Math.min(remaining, MAX_TIMER_DELAY_MS)); + cancelTimeout = this.schedule( + scheduleTimeout, + Math.min(remaining, MAX_TIMER_DELAY_MS), + ); }; scheduleTimeout(); }); try { const instance = this.createInstance(target) as Record; - if (deadline !== undefined && Date.now() >= deadline) { + if (deadline !== undefined && this.now() >= deadline) { triggerTimeout?.(); return await timeoutPromise; } @@ -211,12 +231,12 @@ export class TaskRunner { ? Promise.race([handlerPromise, timeoutPromise]) : handlerPromise); - if (deadline !== undefined && Date.now() >= deadline) { + if (deadline !== undefined && this.now() >= deadline) { triggerTimeout?.(); return await timeoutPromise; } - if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + cancelTimeout?.(); try { await this.executionManager.complete(startedExecution.id, result); @@ -229,7 +249,7 @@ export class TaskRunner { } return result; } catch (error) { - if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + cancelTimeout?.(); if (timeoutClaimed && timeoutPromise !== undefined) { return await timeoutPromise; } @@ -238,7 +258,7 @@ export class TaskRunner { throw error; } - if (deadline !== undefined && Date.now() >= deadline && triggerTimeout !== undefined) { + if (deadline !== undefined && this.now() >= deadline && triggerTimeout !== undefined) { triggerTimeout(); return await timeoutPromise; } diff --git a/packages/tasks-core/src/tests/TaskRunner.spec.ts b/packages/tasks-core/src/tests/TaskRunner.spec.ts index 7b5eb3154..b359a3a3b 100644 --- a/packages/tasks-core/src/tests/TaskRunner.spec.ts +++ b/packages/tasks-core/src/tests/TaskRunner.spec.ts @@ -636,6 +636,56 @@ describe("TaskRunner", () => { expect(mockExecutionManager.fail).not.toHaveBeenCalled(); }); + it("should use injected time and scheduling boundaries for deterministic timeouts", async () => { + let now = new Date("2026-01-01T00:00:00.000Z").getTime(); + let scheduledTimeout: (() => void) | undefined; + let receivedContext: TaskExecutionContext | undefined; + + @Component() + class ControlledTimedTaskHandler { + @Task({ name: "controlled-timed-task", timeout: 100 }) + async handle(_payload: unknown, context: TaskExecutionContext): Promise { + receivedContext = context; + return new Promise((_resolve, reject) => { + context.signal.addEventListener("abort", () => reject(context.signal.reason), { + once: true, + }); + }); + } + } + + Container.set(ControlledTimedTaskHandler, new ControlledTimedTaskHandler()); + registry.collectFromMetadata(); + mockExecutionManager.start = vi.fn().mockResolvedValue( + execution({ + type: "controlled-timed-task", + payload: {}, + status: "running", + attempts: 1, + startedAt: new Date(now), + timeout: 100, + }), + ); + const runner = new TaskRunner(mockExecutionManager, registry, undefined, { + now: () => now, + schedule: (callback) => { + scheduledTimeout = callback; + return () => { + scheduledTimeout = undefined; + }; + }, + }); + + const result = runner.execute("controlled-timed-task", {}); + await vi.waitFor(() => expect(scheduledTimeout).toBeDefined()); + now += 100; + scheduledTimeout?.(); + + await expect(result).rejects.toBeInstanceOf(TaskExecutionTimeoutProblem); + expect(receivedContext?.signal.aborted).toBe(true); + expect(mockExecutionManager.timeout).toHaveBeenCalledWith("exec-123"); + }); + it("should ignore a handler result that arrives after timeout", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); diff --git a/packages/testing/README.md b/packages/testing/README.md index 8d1ee953c..6cc882875 100644 --- a/packages/testing/README.md +++ b/packages/testing/README.md @@ -12,7 +12,7 @@ const response = await app.get("/users"); Production-parity tests pass the same bootstrap export used by the deployed application: ```typescript -import { createTestKernel } from "@croco/testing"; +import { createTestKernel, fixedClock, seededIds } from "@croco/testing"; import { createCrocoApp } from "../app"; await using test = await createTestKernel({ @@ -28,12 +28,29 @@ expect(test.fidelity).toEqual({ }); ``` +Deterministic scenarios opt into kernel-owned controls instead of patching process globals: + +```typescript +await using test = await createTestKernel({ + bootstrap: createCrocoApp, + clock: fixedClock("2026-01-01T00:00:00Z"), + fidelity: "application", + ids: seededIds("invitation-retry"), + network: "deny", + scenarioId: "invitation-retry", +}); + +await test.clock.advanceBy("30s"); +test.expectClean(); +``` + ## API | Helper | Purpose | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `createTestingApp(config)` | Creates an isolated `CrocoApp` with seeded test defaults and HTTP request helpers. | | `createTestKernel(config)` | Boots the real application bootstrap inside an isolated runtime scope and reports application or adapter fidelity as structured evidence. | +| `fixedClock(initial)` / `seededIds(seed)` | Creates virtual time and deterministic IDs/random values for TestKernel-controlled retry, timeout, rate-limit, task, and provider fixtures. | | `createTestingHarness(app)` | Wraps an existing `CrocoApp` with the same request and contract helpers. | | `createEventTestingHarness(config)` | Creates an isolated in-memory event bus and dispatches decorated handlers. | | `createTestingRequestContext(config)` | Builds a deterministic request/runtime context for service tests. | @@ -84,6 +101,32 @@ cleanup once; cleanup failures reject with `TestKernelDisposalProblem`. A bootst resources before it can return an app can call `context.onCleanup()` so failure-path cleanup is also guaranteed. +## Deterministic Runtime Controls + +Each `TestKernel` owns a `clock`, `ids`, `random`, `environment`, `network`, and `replay` record. +The bootstrap callback receives the same controls, so production code can consume existing explicit +dependency seams instead of requiring a test-only branch. For example, pass `context.retry` to +retry backoff dependencies, `() => context.clock.now` to an +`InMemoryIdempotencyStore`, and `() => context.clock.now.getTime()` plus `context.random.next` to +an in-memory rate-limit store. Task timeout runners accept the same clock through +`now: () => context.clock.now.getTime()` and schedule timeouts with +`(callback, delayMs) => context.clock.schedule(callback, delayMs, "task-timeout")`. +`TestClock.schedule()` is for Croco-owned scheduler boundaries; `advanceBy()` and `drain()` run +only that queued work deterministically. + +Environment overrides are immutable per-kernel snapshots. They do not mutate `process.env`, which +makes concurrent kernels safe and leaves the ambient environment unchanged. `network: "deny"` is +the default and rejects calls made through `context.network.fetch()` with a stable host and recovery +diagnostic. It intentionally does not intercept arbitrary `globalThis.fetch` calls. Use explicit +runtime dependencies for provider calls rather than global monkey patches. + +`test.expectClean()` reports pending virtual scheduled work, explicit `test.track()` operations, and +pending after-commit hooks with category and source evidence. Use `test.waitUntil()`, +`test.trackEventHandler()`, `test.trackSpan()`, or `test.trackResource()` at the matching adapter +boundary so those outstanding operations are included too. `test.replay` records the scenario ID, +seed, and virtual time needed to reproduce a failing deterministic scenario. User-owned timers, +promises, and network calls outside these explicit boundaries remain outside the virtual scheduler. + `createTestingApp`, `createEventTestingHarness`, and `resetCrocoTestingContext` reset the root Croco DI container, install a silent logger, replace the health/error defaults, and seed an inactive `TestingTransactionContext` unless `transactionContext: false` is passed. Request helpers execute diff --git a/packages/testing/package.json b/packages/testing/package.json index be729e26d..0ffcbf557 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -60,6 +60,7 @@ "@croco/openapi-spec": "workspace:*", "@croco/problems-core": "workspace:*", "@croco/ratelimit-core": "workspace:*", + "@croco/retry-core": "workspace:*", "@croco/storage-core": "workspace:*", "@croco/telemetry-api": "workspace:*", "@croco/transports-http": "workspace:*", diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index ab1bd2826..ccbb135ba 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -83,6 +83,7 @@ export { TestKernelDisposedProblem, TestKernelDisposalProblem, TestKernelHttp, + TestKernelLeakProblem, TestKernelResourceFidelityProblem, TestKernelResourceNotFoundProblem, TestKernelResourceRegistrationProblem, @@ -93,10 +94,12 @@ export { type TestKernelBootstrapResult, type TestKernelEvidence, type TestKernelFidelity, + type TestKernelLeak, type TestKernelOptions, type TestKernelResourceEvidence, type TestKernelResourceObligation, type TestKernelRuntime, + type TestKernelTrackedWork, type TestKernelValidationFidelity, type TestResource, type TestResourceDiagnostic, @@ -106,6 +109,23 @@ export { type TestResourceMode, type TestResourceStartContext, } from "./libs/TestKernel"; +export { + fixedClock, + seededIds, + TestClock, + TestEnvironment, + TestIdSource, + TestKernelOutboundCallProblem, + TestNetwork, + TestRandomSource, + TestRuntime, + type TestDuration, + type TestEnvironmentOverrides, + type TestReplayMetadata, + type TestRetryDependencies, + type TestRuntimeOptions, + type TestScheduledWork, +} from "./libs/TestRuntime"; export { installTestingTelemetryCapture, TestingTelemetryCapture, diff --git a/packages/testing/src/libs/TestKernel.ts b/packages/testing/src/libs/TestKernel.ts index 8a335da45..37a8f102f 100644 --- a/packages/testing/src/libs/TestKernel.ts +++ b/packages/testing/src/libs/TestKernel.ts @@ -20,6 +20,19 @@ import { type TestingRequestOptions, type TestingTransactionContext, } from "./testing"; +import { TestRuntime } from "./TestRuntime"; +import type { + TestClock, + TestEnvironment, + TestIdSource, + TestNetwork, + TestRandomSource, + TestEnvironmentOverrides, + TestReplayMetadata, + TestRetryDependencies, + TestRuntimeOptions, + TestScheduledWork, +} from "./TestRuntime"; export type TestKernelBootstrapResult = | CrocoApp @@ -45,9 +58,30 @@ export type TestKernelEvidence = { readonly status: number; }; +export type TestKernelLeak = { + readonly category: + | "after-commit" + | "event-handler" + | "operation" + | "resource" + | "scheduled-work" + | "span" + | "wait-until"; + readonly source: string; +}; + +export type TestKernelTrackedWork = Pick; + export type TestKernelBootstrapContext = { + readonly clock: TestClock; + readonly environment: TestEnvironment; readonly fidelity: TestKernelBootFidelity; + readonly ids: TestIdSource; + readonly network: TestNetwork; readonly onCleanup: (cleanup: () => Promise | void) => void; + readonly random: TestRandomSource; + readonly replay: TestReplayMetadata; + readonly retry: TestRetryDependencies; readonly runtime: TestKernelRuntime; }; @@ -105,9 +139,15 @@ type TestKernelCommonOptions = { context: TestKernelBootstrapContext, ) => Promise | TestKernelBootstrapResult; readonly dispose?: (app: CrocoApp) => Promise | void; + readonly clock?: TestRuntimeOptions["clock"]; + readonly environment?: TestEnvironmentOverrides; + readonly ids?: TestRuntimeOptions["ids"]; + readonly network?: TestRuntimeOptions["network"]; readonly obligations?: readonly TestKernelResourceObligation[]; readonly resources?: readonly TestResource[]; + readonly scenarioId?: string; readonly testId?: string; + readonly transactionContext?: TestingTransactionContext; readonly validation?: Partial; readonly workerId?: string; }; @@ -177,6 +217,17 @@ export class TestKernelDisposedProblem extends Problem { } } +export class TestKernelLeakProblem extends Problem { + constructor(leaks: readonly TestKernelLeak[], replay: TestReplayMetadata) { + super( + "testing/test-kernel-leak", + ProblemCategory.InternalServerError, + `TestKernel detected ${leaks.length} pending work item(s). Drain the TestClock, await the operation, or flush after-commit hooks before cleanup.`, + { extensions: { leaks, replay } }, + ); + } +} + export class TestKernelResourceFidelityProblem extends Problem { constructor(obligation: TestKernelResourceObligation, fidelity: TestResourceFidelity) { super( @@ -254,16 +305,23 @@ export class TestKernelHttp { } export class TestKernel implements AsyncDisposable { + readonly clock: TestClock; + readonly environment: TestEnvironment; readonly http: TestKernelHttp; + readonly ids: TestIdSource; + readonly network: TestNetwork; + readonly random: TestRandomSource; + readonly retry: TestRetryDependencies; readonly transactionContext: TestingTransactionContext; private disposal: Promise | undefined; private disposed = false; private readonly evidenceBuffer: TestKernelEvidence[] = []; - private readonly inFlight = new Set>(); + private readonly inFlight = new Map, TestKernelTrackedWork>(); constructor( readonly app: CrocoApp, readonly fidelity: TestKernelFidelity, + private readonly controls: TestRuntime, private readonly scope: ContainerScope, transactionContext: TestingTransactionContext, private readonly baseUrl: string, @@ -273,7 +331,13 @@ export class TestKernel implements AsyncDisposable { private readonly resourceConnections: ReadonlyMap, unknown>, private readonly resourceEvidenceBuffer: readonly TestKernelResourceEvidence[], ) { + this.clock = controls.clock; + this.environment = controls.environment; this.http = new TestKernelHttp(this); + this.ids = controls.ids; + this.network = controls.network; + this.random = controls.random; + this.retry = controls.retry; this.transactionContext = transactionContext; } @@ -281,6 +345,10 @@ export class TestKernel implements AsyncDisposable { return [...this.evidenceBuffer]; } + get replay(): TestReplayMetadata { + return this.controls.replay; + } + get resourceEvidence(): readonly TestKernelResourceEvidence[] { return [...this.resourceEvidenceBuffer]; } @@ -290,7 +358,9 @@ export class TestKernel implements AsyncDisposable { run(fn: () => Promise | T): Promise | T { this.assertActive(); const result = this.scope.run(fn); - return result instanceof Promise ? this.track(result) : result; + return result instanceof Promise + ? this.track(result, { category: "operation", source: "run" }) + : result; } get(token: TokenIdentifier): T { @@ -308,7 +378,38 @@ export class TestKernel implements AsyncDisposable { request(path: string | URL | Request, options: TestingRequestOptions = {}): Promise { this.assertActive(); - return this.track(this.dispatchRequest(path, options)); + return this.track(this.dispatchRequest(path, options), { + category: "operation", + source: "request", + }); + } + + track(operation: Promise, work: TestKernelTrackedWork): Promise { + this.assertActive(); + return this.trackInFlight(operation, work); + } + + waitUntil(operation: Promise, source = "wait-until"): Promise { + return this.track(operation, { category: "wait-until", source }); + } + + trackEventHandler(operation: Promise, source: string): Promise { + return this.track(operation, { category: "event-handler", source }); + } + + trackResource(operation: Promise, source: string): Promise { + return this.track(operation, { category: "resource", source }); + } + + trackSpan(operation: Promise, source: string): Promise { + return this.track(operation, { category: "span", source }); + } + + expectClean(): void { + const leaks = this.collectLeaks(); + if (leaks.length > 0) { + throw new TestKernelLeakProblem(leaks, this.replay); + } } private async dispatchRequest( @@ -318,7 +419,7 @@ export class TestKernel implements AsyncDisposable { const request = toRequest(path, options, this.baseUrl); const response = await this.scope.run(async () => this.lambdaHandler - ? dispatchLambdaRequest(this.lambdaHandler, request) + ? dispatchLambdaRequest(this.lambdaHandler, request, this) : this.nodeHandler ? this.nodeHandler(request) : this.app.fetch(request, { platform: "node" }), @@ -345,9 +446,13 @@ export class TestKernel implements AsyncDisposable { } private async disposeOnce(): Promise { - await Promise.allSettled(this.inFlight); const failures = await runCleanupSequence(this.scope, this.cleanupOperations); + const leaks = this.collectLeaks(); + if (leaks.length > 0) { + failures.unshift(new TestKernelLeakProblem(leaks, this.replay)); + } + if (failures.length > 0) { throw new TestKernelDisposalProblem(failures); } @@ -363,8 +468,22 @@ export class TestKernel implements AsyncDisposable { } } - private track(operation: Promise): Promise { - this.inFlight.add(operation); + private collectLeaks(): TestKernelLeak[] { + const operationLeaks = [...this.inFlight.values()]; + const scheduledLeaks = this.clock.pendingWork.map((work: TestScheduledWork) => ({ + category: "scheduled-work" as const, + source: work.source, + })); + const afterCommitCount = this.transactionContext.getPendingAfterCommitHookCount(); + const afterCommitLeaks = Array.from({ length: afterCommitCount }, () => ({ + category: "after-commit" as const, + source: "transaction-context", + })); + return [...operationLeaks, ...scheduledLeaks, ...afterCommitLeaks]; + } + + private trackInFlight(operation: Promise, work: TestKernelTrackedWork): Promise { + this.inFlight.set(operation, work); void operation.then( () => this.inFlight.delete(operation), () => this.inFlight.delete(operation), @@ -376,19 +495,33 @@ export class TestKernel implements AsyncDisposable { export async function createTestKernel(options: TestKernelOptions): Promise { const scope = Container.createScope(); const runtime = options.fidelity === "adapter" ? (options.adapter ?? "node") : "node"; + const controls = new TestRuntime({ + clock: options.clock, + environment: options.environment, + ids: options.ids, + network: options.network, + scenarioId: options.scenarioId, + }); const registeredCleanups: Array<() => Promise | void> = []; const resourceCleanups: Array<() => Promise | void> = []; - const testId = options.testId ?? `test-${crypto.randomUUID()}`; + const testId = options.testId ?? controls.ids.next("test"); const workerId = options.workerId ?? process.env["VITEST_POOL_ID"] ?? process.env["CI_NODE_INDEX"] ?? `process-${process.pid}`; const fidelityContext: TestKernelBootstrapContext = { + clock: controls.clock, + environment: controls.environment, fidelity: options.fidelity, + ids: controls.ids, + network: controls.network, onCleanup(cleanup) { registeredCleanups.push(cleanup); }, + random: controls.random, + replay: controls.replay, + retry: controls.retry, runtime, }; let app: CrocoApp | undefined; @@ -408,7 +541,7 @@ export async function createTestKernel(options: TestKernelOptions): Promise { EventBusConfig.setInstance(new EventBusConfig()); ShutdownManager.getInstance(); - const transactionContext = createTestingTransactionContext(); + const transactionContext = options.transactionContext ?? createTestingTransactionContext(); const resources = options.resources ?? []; const resourceIds = new Set(); @@ -489,6 +622,7 @@ export async function createTestKernel(options: TestKernelOptions): Promise { +async function dispatchLambdaRequest( + handler: LambdaHandler, + request: Request, + controls: Pick, +): Promise { const url = new URL(request.url); const headers = Object.fromEntries(request.headers.entries()); const body = @@ -589,16 +727,16 @@ async function dispatchLambdaRequest(handler: LambdaHandler, request: Request): sourceIp: "127.0.0.1", userAgent: headers["user-agent"] ?? "croco-test-kernel", }, - requestId: `test-kernel-${crypto.randomUUID()}`, + requestId: controls.ids.next("lambda-request"), routeKey: `${request.method} ${url.pathname}`, stage: "$default", - time: new Date(0).toUTCString(), - timeEpoch: 0, + time: controls.clock.now.toUTCString(), + timeEpoch: controls.clock.now.getTime(), }, isBase64Encoded: false, ...(body === undefined ? {} : { body }), }; - const lambdaResponse = await handler(event, createLambdaContext()); + const lambdaResponse = await handler(event, createLambdaContext(controls.ids)); const responseHeaders = new Headers(lambdaResponse.headers); for (const cookie of lambdaResponse.cookies ?? []) { responseHeaders.append("set-cookie", cookie); @@ -615,14 +753,14 @@ async function dispatchLambdaRequest(handler: LambdaHandler, request: Request): }); } -function createLambdaContext(): LambdaContext { +function createLambdaContext(ids: TestIdSource): LambdaContext { return { callbackWaitsForEmptyEventLoop: false, functionName: "croco-test-kernel", functionVersion: "$LATEST", invokedFunctionArn: "arn:aws:lambda:local:0:function:croco-test-kernel", memoryLimitInMB: "128", - awsRequestId: crypto.randomUUID(), + awsRequestId: ids.next("lambda"), logGroupName: "/aws/lambda/croco-test-kernel", logStreamName: "test", getRemainingTimeInMillis: () => 30_000, diff --git a/packages/testing/src/libs/TestRuntime.ts b/packages/testing/src/libs/TestRuntime.ts new file mode 100644 index 000000000..404162b46 --- /dev/null +++ b/packages/testing/src/libs/TestRuntime.ts @@ -0,0 +1,269 @@ +import { Problem, ProblemCategory } from "@croco/problems-core"; +import type { BackoffDependencies } from "@croco/retry-core"; + +export type TestDuration = number | `${number}${"ms" | "s" | "m"}`; + +export type TestScheduledWork = { + readonly dueAt: string; + readonly id: string; + readonly source: string; +}; + +export type TestReplayMetadata = { + readonly scenarioId: string; + readonly seed: string; + readonly virtualTime: string; +}; + +export type TestRetryDependencies = Required; + +export type TestEnvironmentOverrides = Readonly>; + +export type TestRuntimeOptions = { + readonly clock?: Date | string | TestClock; + readonly environment?: TestEnvironmentOverrides; + readonly ids?: string | TestIdSource; + readonly network?: "allow" | "deny"; + readonly scenarioId?: string; +}; + +type ScheduledCallback = () => void | Promise; + +type ScheduledEntry = { + readonly callback: ScheduledCallback; + readonly dueAtMs: number; + readonly id: string; + readonly source: string; +}; + +export class TestKernelOutboundCallProblem extends Problem { + constructor(host: string) { + super( + "testing/test-kernel-outbound-call", + ProblemCategory.InternalServerError, + `TestKernel blocked an outbound call to '${host}'. Register a provider fake or set network: 'allow' when this call is intentional.`, + { + extensions: { + host, + recovery: "Register a provider fake or explicitly allow this outbound call.", + }, + }, + ); + } +} + +export class TestClock { + private currentTimeMs: number; + private scheduledSequence = 0; + private readonly scheduled = new Map(); + + constructor(initial: Date | string = "2026-01-01T00:00:00.000Z") { + const time = new Date(initial).getTime(); + if (Number.isNaN(time)) { + throw new RangeError( + `TestClock requires a valid initial time; received '${String(initial)}'.`, + ); + } + this.currentTimeMs = time; + } + + get now(): Date { + return new Date(this.currentTimeMs); + } + + get pendingWork(): readonly TestScheduledWork[] { + return [...this.scheduled.values()] + .sort((left, right) => left.dueAtMs - right.dueAtMs || left.id.localeCompare(right.id)) + .map(({ dueAtMs, id, source }) => ({ dueAt: new Date(dueAtMs).toISOString(), id, source })); + } + + schedule( + callback: ScheduledCallback, + delay: TestDuration, + source = "scheduled-work", + ): () => void { + const id = `scheduled-${++this.scheduledSequence}`; + this.scheduled.set(id, { + callback, + dueAtMs: this.currentTimeMs + parseDuration(delay), + id, + source, + }); + return () => this.scheduled.delete(id); + } + + sleep(delay: TestDuration, source = "sleep"): Promise { + return new Promise((resolve) => { + this.schedule(resolve, delay, source); + }); + } + + async advanceBy(duration: TestDuration): Promise { + const targetTime = this.currentTimeMs + parseDuration(duration); + await this.drainUntil(targetTime); + this.currentTimeMs = targetTime; + } + + async drain(): Promise { + while (this.scheduled.size > 0) { + const next = this.nextScheduled(); + if (!next) return; + await this.drainUntil(next.dueAtMs); + } + } + + private async drainUntil(targetTime: number): Promise { + while (true) { + const next = this.nextScheduled(); + if (!next || next.dueAtMs > targetTime) return; + + this.scheduled.delete(next.id); + this.currentTimeMs = next.dueAtMs; + await next.callback(); + } + } + + private nextScheduled(): ScheduledEntry | undefined { + return [...this.scheduled.values()].sort( + (left, right) => left.dueAtMs - right.dueAtMs || left.id.localeCompare(right.id), + )[0]; + } +} + +export class TestRandomSource { + private state: number; + + constructor(readonly seed: string) { + this.state = hashSeed(seed) || 1; + } + + next(): number { + this.state ^= this.state << 13; + this.state ^= this.state >>> 17; + this.state ^= this.state << 5; + return (this.state >>> 0) / 0x1_0000_0000; + } +} + +export class TestIdSource { + private sequence = 0; + readonly random: TestRandomSource; + + constructor(readonly seed: string) { + this.random = new TestRandomSource(seed); + } + + next(prefix = "test"): string { + this.sequence += 1; + const entropy = Math.floor(this.random.next() * 0x1_0000_0000) + .toString(36) + .padStart(7, "0"); + return `${prefix}-${this.seed}-${this.sequence}-${entropy}`; + } +} + +export class TestEnvironment { + private readonly values: Readonly>; + + constructor(overrides: TestEnvironmentOverrides = {}) { + this.values = Object.freeze({ ...process.env, ...overrides }); + } + + get(name: string): string | undefined { + return this.values[name]; + } + + toObject(): Readonly> { + return { ...this.values }; + } +} + +export class TestNetwork { + constructor(private readonly mode: "allow" | "deny") {} + + fetch(input: string | URL | Request, init?: RequestInit): Promise { + const url = input instanceof Request ? input.url : input.toString(); + if (this.mode === "deny") { + throw new TestKernelOutboundCallProblem(new URL(url).host); + } + return fetch(input, init); + } +} + +export class TestRuntime { + readonly clock: TestClock; + readonly environment: TestEnvironment; + readonly ids: TestIdSource; + readonly network: TestNetwork; + readonly random: TestRandomSource; + readonly scenarioId: string; + + constructor(options: TestRuntimeOptions = {}) { + this.ids = + typeof options.ids === "string" + ? new TestIdSource(options.ids) + : options.ids + ? new TestIdSource(options.ids.seed) + : new TestIdSource(crypto.randomUUID()); + this.clock = + options.clock instanceof TestClock + ? new TestClock(options.clock.now) + : new TestClock(options.clock); + this.environment = new TestEnvironment(options.environment); + this.network = new TestNetwork(options.network ?? "deny"); + this.random = this.ids.random; + this.scenarioId = options.scenarioId ?? this.ids.next("scenario"); + } + + get replay(): TestReplayMetadata { + return Object.freeze({ + scenarioId: this.scenarioId, + seed: this.ids.seed, + virtualTime: this.clock.now.toISOString(), + }); + } + + get retry(): TestRetryDependencies { + return { + random: () => this.random.next(), + sleep: (delayMs: number) => this.clock.sleep(delayMs, "retry:backoff"), + }; + } +} + +export function fixedClock(initial: Date | string): TestClock { + return new TestClock(initial); +} + +export function seededIds(seed: string): TestIdSource { + return new TestIdSource(seed); +} + +function parseDuration(value: TestDuration): number { + if (typeof value === "number") { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError( + `Test duration must be a non-negative safe integer; received '${value}'.`, + ); + } + return value; + } + + const match = /^(\d+)(ms|s|m)$/.exec(value); + if (!match) { + throw new RangeError(`Test duration must use ms, s, or m units; received '${value}'.`); + } + const amount = Number(match[1]); + const unit = match[2]; + const multiplier = unit === "m" ? 60_000 : unit === "s" ? 1_000 : 1; + return amount * multiplier; +} + +function hashSeed(seed: string): number { + let hash = 2_166_136_261; + for (const character of seed) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16_777_619); + } + return hash >>> 0; +} diff --git a/packages/testing/src/tests/TestKernel.spec.ts b/packages/testing/src/tests/TestKernel.spec.ts index 885d45d0c..11f5a8642 100644 --- a/packages/testing/src/tests/TestKernel.spec.ts +++ b/packages/testing/src/tests/TestKernel.spec.ts @@ -10,9 +10,13 @@ import { import { beforeEach, describe, expect, it } from "vitest"; import { createTestKernel, + createTestingTransactionContext, + fixedClock, + seededIds, type TestKernel, TestKernelDisposalProblem, TestKernelDisposedProblem, + TestKernelLeakProblem, type TestKernelOptions, TestKernelResourceFidelityProblem, TestKernelResourceNotFoundProblem, @@ -610,11 +614,7 @@ describe("TestKernel", () => { expect(() => kernelRef.run(() => undefined)).toThrow(TestKernelDisposedProblem); }); - it("waits for in-flight kernel work before cleanup", async () => { - let resumeOperation!: () => void; - const resume = new Promise((resolve) => { - resumeOperation = resolve; - }); + it("reports unresolved tracked work without hanging cleanup", async () => { let cleanupCalls = 0; const kernel = await createTestKernel({ bootstrap: () => bootstrapProductionApp("in-flight"), @@ -623,18 +623,10 @@ describe("TestKernel", () => { }, fidelity: "application", }); - const operation = kernel.run(async () => { - await resume; - return Container.get(KernelValueService).value; - }); - const disposal = kernel.dispose(); - await Promise.resolve(); - expect(cleanupCalls).toBe(0); + kernel.waitUntil(new Promise(() => undefined), "response.flush"); - resumeOperation(); - await expect(operation).resolves.toBe("in-flight"); - await disposal; + await expect(kernel.dispose()).rejects.toBeInstanceOf(TestKernelDisposalProblem); expect(cleanupCalls).toBe(1); }); @@ -690,4 +682,135 @@ describe("TestKernel", () => { await expect(kernel.dispose()).rejects.toBeInstanceOf(TestKernelDisposalProblem); }); + + it("injects deterministic runtime controls and reports replay metadata", async () => { + let bootstrapReplay!: { + readonly scenarioId: string; + readonly seed: string; + readonly virtualTime: string; + }; + const kernel = await createTestKernel({ + bootstrap: (context) => { + bootstrapReplay = context.replay; + expect(context.environment.get("CROCO_TEST_KERNEL_SCOPE")).toBe("first"); + expect(context.ids.next("bootstrap")).toContain("retry-scenario"); + return bootstrapProductionApp("controls"); + }, + clock: "2026-02-03T04:05:06.000Z", + environment: { CROCO_TEST_KERNEL_SCOPE: "first" }, + fidelity: "application", + ids: "retry-scenario", + scenarioId: "retry-timeout", + }); + + expect(bootstrapReplay).toEqual({ + scenarioId: "retry-timeout", + seed: "retry-scenario", + virtualTime: "2026-02-03T04:05:06.000Z", + }); + expect(kernel.replay).toEqual(bootstrapReplay); + expect(process.env["CROCO_TEST_KERNEL_SCOPE"]).not.toBe("first"); + + await kernel.dispose(); + }); + + it("clones caller-provided controls so concurrent kernels do not share time or ID state", async () => { + const sharedClock = fixedClock("2026-01-01T00:00:00.000Z"); + const sharedIds = seededIds("concurrent-kernels"); + const [first, second] = await Promise.all( + ["first", "second"].map((value) => + createTestKernel({ + bootstrap: () => bootstrapProductionApp(value), + clock: sharedClock, + fidelity: "application", + ids: sharedIds, + }), + ), + ); + + await first.clock.advanceBy("30s"); + const firstExtraId = first.ids.next("extra"); + + expect(first.clock.now.toISOString()).toBe("2026-01-01T00:00:30.000Z"); + expect(second.clock.now.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + expect(second.ids.next("extra")).toBe(firstExtraId); + + await Promise.all([first.dispose(), second.dispose()]); + }); + + it("reports pending scheduled work and after-commit hooks with stable leak evidence", async () => { + const kernel = await createTestKernel({ + bootstrap: () => bootstrapProductionApp("leaks"), + fidelity: "application", + transactionContext: createTestingTransactionContext({ inTransaction: true }), + }); + + kernel.clock.schedule(() => undefined, "30s", "retry:payment"); + await kernel.transactionContext.runInTransaction(() => { + kernel.transactionContext.onAfterCommit(() => undefined); + }); + + expect(() => kernel.expectClean()).toThrow(TestKernelLeakProblem); + try { + kernel.expectClean(); + } catch (error) { + expect(error).toMatchObject({ + code: "testing/test-kernel-leak", + extensions: { + leaks: expect.arrayContaining([ + { category: "scheduled-work", source: "retry:payment" }, + { category: "after-commit", source: "transaction-context" }, + ]), + }, + }); + } + + await kernel.clock.advanceBy("30s"); + let advancedLeak: unknown; + try { + kernel.expectClean(); + } catch (error) { + advancedLeak = error; + } + expect(advancedLeak).toMatchObject({ + extensions: { + replay: { + scenarioId: expect.any(String), + seed: expect.any(String), + virtualTime: "2026-01-01T00:00:30.000Z", + }, + }, + }); + await kernel.transactionContext.flushAfterCommitHooks(); + kernel.expectClean(); + await kernel.dispose(); + }); + + it("reports tracked adapter work by its runtime boundary", async () => { + const kernel = await createTestKernel({ + bootstrap: () => bootstrapProductionApp("tracked-boundaries"), + fidelity: "application", + }); + + kernel.trackEventHandler(new Promise(() => undefined), "events:payment.created"); + kernel.trackSpan(new Promise(() => undefined), "telemetry:payment.create"); + kernel.trackResource(new Promise(() => undefined), "postgres:connection"); + + let error: unknown; + try { + kernel.expectClean(); + } catch (caught) { + error = caught; + } + expect(error).toMatchObject({ + extensions: { + leaks: expect.arrayContaining([ + { category: "event-handler", source: "events:payment.created" }, + { category: "span", source: "telemetry:payment.create" }, + { category: "resource", source: "postgres:connection" }, + ]), + }, + }); + await expect(kernel.dispose()).rejects.toBeInstanceOf(TestKernelDisposalProblem); + }); }); diff --git a/packages/testing/src/tests/TestRuntime.spec.ts b/packages/testing/src/tests/TestRuntime.spec.ts new file mode 100644 index 000000000..14145ee3f --- /dev/null +++ b/packages/testing/src/tests/TestRuntime.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { createSlidingWindowPolicy, SlidingWindowInMemoryStore } from "@croco/ratelimit-core"; +import { ExponentialBackoff } from "@croco/retry-core"; +import { fixedClock, seededIds, TestKernelOutboundCallProblem, TestRuntime } from "../index"; + +describe("TestRuntime", () => { + it("advances virtual time and drains only Croco-owned scheduled work in order", async () => { + const clock = fixedClock("2026-01-01T00:00:00.000Z"); + const events: string[] = []; + clock.schedule( + () => { + events.push("late"); + }, + "30s", + "retry:late", + ); + clock.schedule( + () => { + events.push("early"); + clock.schedule( + () => { + events.push("nested"); + }, + "5s", + "retry:nested", + ); + }, + "10s", + "retry:early", + ); + + await clock.advanceBy("15s"); + + expect(events).toEqual(["early", "nested"]); + expect(clock.now.toISOString()).toBe("2026-01-01T00:00:15.000Z"); + expect(clock.pendingWork).toEqual([ + { + dueAt: "2026-01-01T00:00:30.000Z", + id: "scheduled-1", + source: "retry:late", + }, + ]); + }); + + it("replays seeded ids and random values without touching process state", () => { + const first = seededIds("invitation-retry"); + const second = seededIds("invitation-retry"); + const environmentBefore = process.env["CROCO_TEST_RUNTIME_FIXTURE"]; + const runtime = new TestRuntime({ + environment: { CROCO_TEST_RUNTIME_FIXTURE: "scoped" }, + ids: "invitation-retry", + scenarioId: "retry-replay", + }); + + expect(first.next("invitation")).toBe(second.next("invitation")); + expect(first.random.next()).toBe(second.random.next()); + expect(runtime.environment.get("CROCO_TEST_RUNTIME_FIXTURE")).toBe("scoped"); + expect(process.env["CROCO_TEST_RUNTIME_FIXTURE"]).toBe(environmentBefore); + expect(runtime.replay).toEqual({ + scenarioId: "retry-replay", + seed: "invitation-retry", + virtualTime: "2026-01-01T00:00:00.000Z", + }); + }); + + it("rejects outbound calls by default with a provider-facing diagnostic", () => { + const runtime = new TestRuntime({ network: "deny" }); + + expect(() => runtime.network.fetch("https://provider.example.test/v1/send")).toThrow( + TestKernelOutboundCallProblem, + ); + try { + runtime.network.fetch("https://provider.example.test/v1/send"); + } catch (error) { + expect(error).toMatchObject({ + code: "testing/test-kernel-outbound-call", + extensions: { + host: "provider.example.test", + recovery: "Register a provider fake or explicitly allow this outbound call.", + }, + }); + } + }); + + it("supplies virtual time and seeded entropy to rate-limit boundaries without global timers", async () => { + const runtime = new TestRuntime({ ids: "rate-limit", network: "deny" }); + const store = new SlidingWindowInMemoryStore({ + now: () => runtime.clock.now.getTime(), + pruneIntervalMs: 0, + random: () => runtime.random.next(), + }); + const policy = createSlidingWindowPolicy("test-runtime", 1, 30_000); + + await expect(store.check("tenant:one", policy)).resolves.toMatchObject({ + remaining: 0, + success: true, + }); + await expect(store.check("tenant:one", policy)).resolves.toMatchObject({ success: false }); + + await runtime.clock.advanceBy(30_001); + + await expect(store.check("tenant:one", policy)).resolves.toMatchObject({ + remaining: 0, + success: true, + }); + }); + + it("drains retry-core backoff through kernel-owned virtual time", async () => { + const runtime = new TestRuntime({ ids: "retry-replay" }); + const retry = new ExponentialBackoff({ delay: 30_000, jitter: false }, runtime.retry); + const wait = retry.wait(0); + + expect(runtime.clock.pendingWork).toMatchObject([{ source: "retry:backoff" }]); + + await runtime.clock.advanceBy("30s"); + + await expect(wait).resolves.toBeUndefined(); + }); +}); diff --git a/packages/testing/tsconfig.json b/packages/testing/tsconfig.json index 01aa2f479..d593c2efd 100644 --- a/packages/testing/tsconfig.json +++ b/packages/testing/tsconfig.json @@ -16,6 +16,7 @@ "@croco/protocols-core": ["../protocols-core/src/index.ts"], "@croco/protocols-rest": ["../protocols-rest/src/index.ts"], "@croco/ratelimit-core": ["../ratelimit-core/src/index.ts"], + "@croco/retry-core": ["../retry-core/src/index.ts"], "@croco/storage-core": ["../storage-core/src/index.ts"], "@croco/telemetry-api": ["../telemetry-api/src/index.ts"], "@croco/testing/drizzle": ["./src/drizzle.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ad78c94a..e922aead6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3314,6 +3314,9 @@ importers: '@croco/ratelimit-core': specifier: workspace:* version: link:../ratelimit-core + '@croco/retry-core': + specifier: workspace:* + version: link:../retry-core '@croco/storage-core': specifier: workspace:* version: link:../storage-core diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index 52d18abe2..9ddef8fee 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -24137,6 +24137,11 @@ "name": "TaskReference", "exportKind": "named", "source": "./libs/types" + }, + { + "name": "TaskRunnerRuntime", + "exportKind": "named", + "source": "./libs/TaskRunner" } ] } @@ -25495,6 +25500,12 @@ "source": "./libs/failure-drill", "declarationKind": "const" }, + { + "name": "fixedClock", + "exportKind": "named", + "source": "./libs/TestRuntime", + "declarationKind": "function" + }, { "name": "installTestingTelemetryCapture", "exportKind": "named", @@ -25585,6 +25596,12 @@ "source": "./libs/testing", "declarationKind": "function" }, + { + "name": "seededIds", + "exportKind": "named", + "source": "./libs/TestRuntime", + "declarationKind": "function" + }, { "name": "serializeOperationalFailureDrillReport", "exportKind": "named", @@ -25603,6 +25620,18 @@ "source": "./libs/test-evidence.mjs", "declarationKind": "const" }, + { + "name": "TestClock", + "exportKind": "named", + "source": "./libs/TestRuntime", + "declarationKind": "class" + }, + { + "name": "TestEnvironment", + "exportKind": "named", + "source": "./libs/TestRuntime", + "declarationKind": "class" + }, { "name": "TestEvidenceContractError", "exportKind": "named", @@ -25621,6 +25650,12 @@ "source": "./libs/test-evidence.mjs", "declarationKind": "function" }, + { + "name": "TestIdSource", + "exportKind": "named", + "source": "./libs/TestRuntime", + "declarationKind": "class" + }, { "name": "TestingTelemetryCapture", "exportKind": "named", @@ -25657,6 +25692,18 @@ "source": "./libs/TestKernel", "declarationKind": "class" }, + { + "name": "TestKernelLeakProblem", + "exportKind": "named", + "source": "./libs/TestKernel", + "declarationKind": "class" + }, + { + "name": "TestKernelOutboundCallProblem", + "exportKind": "named", + "source": "./libs/TestRuntime", + "declarationKind": "class" + }, { "name": "TestKernelResourceFidelityProblem", "exportKind": "named", @@ -25680,6 +25727,24 @@ "exportKind": "named", "source": "./libs/TestKernel", "declarationKind": "class" + }, + { + "name": "TestNetwork", + "exportKind": "named", + "source": "./libs/TestRuntime", + "declarationKind": "class" + }, + { + "name": "TestRandomSource", + "exportKind": "named", + "source": "./libs/TestRuntime", + "declarationKind": "class" + }, + { + "name": "TestRuntime", + "exportKind": "named", + "source": "./libs/TestRuntime", + "declarationKind": "class" } ], "typeExports": [ @@ -26303,6 +26368,16 @@ "exportKind": "named", "source": "./libs/provider-conformance" }, + { + "name": "TestDuration", + "exportKind": "named", + "source": "./libs/TestRuntime" + }, + { + "name": "TestEnvironmentOverrides", + "exportKind": "named", + "source": "./libs/TestRuntime" + }, { "name": "TestEvidenceArtifactProbe", "exportKind": "named", @@ -26473,6 +26548,11 @@ "exportKind": "named", "source": "./libs/TestKernel" }, + { + "name": "TestKernelLeak", + "exportKind": "named", + "source": "./libs/TestKernel" + }, { "name": "TestKernelOptions", "exportKind": "named", @@ -26493,6 +26573,11 @@ "exportKind": "named", "source": "./libs/TestKernel" }, + { + "name": "TestKernelTrackedWork", + "exportKind": "named", + "source": "./libs/TestKernel" + }, { "name": "TestKernelValidationFidelity", "exportKind": "named", @@ -26503,6 +26588,11 @@ "exportKind": "named", "source": "./libs/testing" }, + { + "name": "TestReplayMetadata", + "exportKind": "named", + "source": "./libs/TestRuntime" + }, { "name": "TestResource", "exportKind": "named", @@ -26538,6 +26628,21 @@ "exportKind": "named", "source": "./libs/TestKernel" }, + { + "name": "TestRetryDependencies", + "exportKind": "named", + "source": "./libs/TestRuntime" + }, + { + "name": "TestRuntimeOptions", + "exportKind": "named", + "source": "./libs/TestRuntime" + }, + { + "name": "TestScheduledWork", + "exportKind": "named", + "source": "./libs/TestRuntime" + }, { "name": "UnavailableUsageBillingCapabilityConformanceOptions", "exportKind": "named", From ec6b1bb4d8f75455af7c059f66252770d8e4e31d Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 2 Aug 2026 05:03:24 +0900 Subject: [PATCH 2/9] fix: enforce deterministic test runtime contracts --- docs/problem-code-registry.json | 30 +++++++++++++++++++ .../en/reference/problem-recovery-cookbook.md | 19 ++++++++++++ .../src/generated/problem-code-registry.ts | 30 +++++++++++++++++++ packages/testing/src/index.ts | 1 + packages/testing/src/libs/TestKernel.ts | 15 +++++----- packages/testing/src/libs/TestRuntime.ts | 23 ++++++++++---- .../testing/src/tests/TestRuntime.spec.ts | 15 +++++++++- public-api-surface.snapshot.json | 6 ++++ 8 files changed, 125 insertions(+), 14 deletions(-) diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json index eaf15bfeb..502401f89 100644 --- a/docs/problem-code-registry.json +++ b/docs/problem-code-registry.json @@ -14852,6 +14852,36 @@ } ] }, + { + "code": "testing/test-runtime-configuration", + "category": "ValidationError", + "status": 422, + "title": "Validation Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#testing-test-runtime-configuration", + "recovery": { + "cause": "The request or generated contract failed schema or semantic validation.", + "userAction": "Fix the invalid fields and retry with schema-conformant input.", + "operatorAction": "Inspect schema diagnostics, generated contracts, and validation metadata.", + "retryability": "not-retryable", + "redactionPolicy": "public", + "telemetry": { + "eventName": "croco.problem.info", + "severity": "info", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "lifecycle": { + "status": "active" + }, + "sources": [ + { + "file": "packages/testing/src/libs/TestRuntime.ts", + "line": 57, + "column": 5, + "kind": "problem-constructor" + } + ] + }, { "code": "testing/transaction-context-not-active", "category": "InternalServerError", diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md index 5f221855f..a81705008 100644 --- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md +++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md @@ -508,6 +508,7 @@ This cookbook documents 571 public Croco Problem codes. The deterministic JSON r | [`testing/test-kernel-resource-not-found`](#testing-test-kernel-resource-not-found) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`testing/test-kernel-resource-registration`](#testing-test-kernel-resource-registration) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`testing/test-kernel-validation-policy`](#testing-test-kernel-validation-policy) | InternalServerError | 500 | conditional | operator-only | active | 1 | +| [`testing/test-runtime-configuration`](#testing-test-runtime-configuration) | ValidationError | 422 | not-retryable | public | active | 1 | | [`testing/transaction-context-not-active`](#testing-transaction-context-not-active) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`TOKEN_LIMIT_EXCEEDED`](#token-limit-exceeded) | BadRequest | 400 | not-retryable | public | active | 1 | | [`TOOL_EXECUTION_ERROR`](#tool-execution-error) | InternalServerError | 500 | conditional | operator-only | active | 1 | @@ -9499,6 +9500,24 @@ Sources: - `packages/testing/src/libs/TestKernel.ts:171:5` (problem-constructor) + + +## `testing/test-runtime-configuration` + +- Category: `ValidationError` +- HTTP status: `422` Validation Error +- Retryability: `not-retryable` +- Redaction policy: `public` +- Lifecycle: `active` +- Cause: The request or generated contract failed schema or semantic validation. +- User action: Fix the invalid fields and retry with schema-conformant input. +- Operator action: Inspect schema diagnostics, generated contracts, and validation metadata. +- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/testing/src/libs/TestRuntime.ts:57:5` (problem-constructor) + ## `testing/transaction-context-not-active` diff --git a/packages/problems-core/src/generated/problem-code-registry.ts b/packages/problems-core/src/generated/problem-code-registry.ts index d3d069db4..80f24d52b 100644 --- a/packages/problems-core/src/generated/problem-code-registry.ts +++ b/packages/problems-core/src/generated/problem-code-registry.ts @@ -15484,6 +15484,36 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { }, ], }, + { + code: "testing/test-runtime-configuration", + category: "ValidationError", + status: 422, + title: "Validation Error", + cookbookPath: "/reference/problem-recovery-cookbook/#testing-test-runtime-configuration", + recovery: { + cause: "The request or generated contract failed schema or semantic validation.", + userAction: "Fix the invalid fields and retry with schema-conformant input.", + operatorAction: "Inspect schema diagnostics, generated contracts, and validation metadata.", + retryability: "not-retryable", + redactionPolicy: "public", + telemetry: { + eventName: "croco.problem.info", + severity: "info", + attributes: ["problem.code", "problem.category", "problem.status"], + }, + }, + lifecycle: { + status: "active", + }, + sources: [ + { + file: "packages/testing/src/libs/TestRuntime.ts", + line: 57, + column: 5, + kind: "problem-constructor", + }, + ], + }, { code: "testing/transaction-context-not-active", category: "InternalServerError", diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index ccbb135ba..0fefe34f4 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -119,6 +119,7 @@ export { TestNetwork, TestRandomSource, TestRuntime, + TestRuntimeConfigurationProblem, type TestDuration, type TestEnvironmentOverrides, type TestReplayMetadata, diff --git a/packages/testing/src/libs/TestKernel.ts b/packages/testing/src/libs/TestKernel.ts index 37a8f102f..07e64ab46 100644 --- a/packages/testing/src/libs/TestKernel.ts +++ b/packages/testing/src/libs/TestKernel.ts @@ -495,13 +495,14 @@ export class TestKernel implements AsyncDisposable { export async function createTestKernel(options: TestKernelOptions): Promise { const scope = Container.createScope(); const runtime = options.fidelity === "adapter" ? (options.adapter ?? "node") : "node"; - const controls = new TestRuntime({ - clock: options.clock, - environment: options.environment, - ids: options.ids, - network: options.network, - scenarioId: options.scenarioId, - }); + const runtimeOptions: TestRuntimeOptions = { + ...(options.clock === undefined ? {} : { clock: options.clock }), + ...(options.environment === undefined ? {} : { environment: options.environment }), + ...(options.ids === undefined ? {} : { ids: options.ids }), + ...(options.network === undefined ? {} : { network: options.network }), + ...(options.scenarioId === undefined ? {} : { scenarioId: options.scenarioId }), + }; + const controls = new TestRuntime(runtimeOptions); const registeredCleanups: Array<() => Promise | void> = []; const resourceCleanups: Array<() => Promise | void> = []; const testId = options.testId ?? controls.ids.next("test"); diff --git a/packages/testing/src/libs/TestRuntime.ts b/packages/testing/src/libs/TestRuntime.ts index 404162b46..555a4b382 100644 --- a/packages/testing/src/libs/TestRuntime.ts +++ b/packages/testing/src/libs/TestRuntime.ts @@ -52,6 +52,17 @@ export class TestKernelOutboundCallProblem extends Problem { } } +export class TestRuntimeConfigurationProblem extends Problem { + constructor(field: "clock" | "duration", value: string, expectation: string) { + super( + "testing/test-runtime-configuration", + ProblemCategory.ValidationError, + `Test runtime '${field}' must be ${expectation}; received '${value}'.`, + { extensions: { expectation, field, value } }, + ); + } +} + export class TestClock { private currentTimeMs: number; private scheduledSequence = 0; @@ -60,9 +71,7 @@ export class TestClock { constructor(initial: Date | string = "2026-01-01T00:00:00.000Z") { const time = new Date(initial).getTime(); if (Number.isNaN(time)) { - throw new RangeError( - `TestClock requires a valid initial time; received '${String(initial)}'.`, - ); + throw new TestRuntimeConfigurationProblem("clock", String(initial), "a valid date"); } this.currentTimeMs = time; } @@ -242,8 +251,10 @@ export function seededIds(seed: string): TestIdSource { function parseDuration(value: TestDuration): number { if (typeof value === "number") { if (!Number.isSafeInteger(value) || value < 0) { - throw new RangeError( - `Test duration must be a non-negative safe integer; received '${value}'.`, + throw new TestRuntimeConfigurationProblem( + "duration", + String(value), + "a non-negative safe integer", ); } return value; @@ -251,7 +262,7 @@ function parseDuration(value: TestDuration): number { const match = /^(\d+)(ms|s|m)$/.exec(value); if (!match) { - throw new RangeError(`Test duration must use ms, s, or m units; received '${value}'.`); + throw new TestRuntimeConfigurationProblem("duration", String(value), "an ms, s, or m duration"); } const amount = Number(match[1]); const unit = match[2]; diff --git a/packages/testing/src/tests/TestRuntime.spec.ts b/packages/testing/src/tests/TestRuntime.spec.ts index 14145ee3f..40d1573b8 100644 --- a/packages/testing/src/tests/TestRuntime.spec.ts +++ b/packages/testing/src/tests/TestRuntime.spec.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import { createSlidingWindowPolicy, SlidingWindowInMemoryStore } from "@croco/ratelimit-core"; import { ExponentialBackoff } from "@croco/retry-core"; -import { fixedClock, seededIds, TestKernelOutboundCallProblem, TestRuntime } from "../index"; +import { + fixedClock, + seededIds, + TestKernelOutboundCallProblem, + TestRuntime, + TestRuntimeConfigurationProblem, +} from "../index"; describe("TestRuntime", () => { it("advances virtual time and drains only Croco-owned scheduled work in order", async () => { @@ -63,6 +69,13 @@ describe("TestRuntime", () => { }); }); + it("reports invalid time controls with a stable Problem code", () => { + expect(() => fixedClock("not-a-date")).toThrow(TestRuntimeConfigurationProblem); + expect(() => fixedClock("2026-01-01T00:00:00.000Z").schedule(() => undefined, -1)).toThrow( + TestRuntimeConfigurationProblem, + ); + }); + it("rejects outbound calls by default with a provider-facing diagnostic", () => { const runtime = new TestRuntime({ network: "deny" }); diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index 9ddef8fee..e5923a822 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -25745,6 +25745,12 @@ "exportKind": "named", "source": "./libs/TestRuntime", "declarationKind": "class" + }, + { + "name": "TestRuntimeConfigurationProblem", + "exportKind": "named", + "source": "./libs/TestRuntime", + "declarationKind": "class" } ], "typeExports": [ From a494a896dde42eb33dd898ea6f9f02c73e4cf7ff Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 2 Aug 2026 07:55:44 +0900 Subject: [PATCH 3/9] fix: preserve deterministic test runtime diagnostics --- docs/problem-code-registry.json | 14 +-- .../en/reference/problem-recovery-cookbook.md | 14 +-- .../src/generated/problem-code-registry.ts | 14 +-- packages/ratelimit-core/src/index.ts | 6 +- .../src/libs/InMemoryRateLimitStore.ts | 85 ++++++++++++------- packages/tasks-core/src/index.ts | 4 +- .../tasks-core/src/tests/TaskRunner.spec.ts | 7 +- packages/testing/src/libs/TestKernel.ts | 23 ++++- packages/testing/src/libs/TestRuntime.ts | 10 ++- packages/testing/src/tests/TestKernel.spec.ts | 39 +++++++-- .../testing/src/tests/TestRuntime.spec.ts | 36 ++++---- public-api-surface.snapshot.json | 5 ++ 12 files changed, 174 insertions(+), 83 deletions(-) diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json index 502401f89..943fc0d98 100644 --- a/docs/problem-code-registry.json +++ b/docs/problem-code-registry.json @@ -14636,7 +14636,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 184, + "line": 190, "column": 5, "kind": "problem-constructor" } @@ -14666,7 +14666,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 212, + "line": 218, "column": 5, "kind": "problem-constructor" } @@ -14696,7 +14696,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 222, + "line": 228, "column": 5, "kind": "problem-constructor" } @@ -14756,7 +14756,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 233, + "line": 239, "column": 5, "kind": "problem-constructor" } @@ -14786,7 +14786,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 249, + "line": 255, "column": 5, "kind": "problem-constructor" } @@ -14816,7 +14816,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 262, + "line": 268, "column": 5, "kind": "problem-constructor" } @@ -14846,7 +14846,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 171, + "line": 177, "column": 5, "kind": "problem-constructor" } diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md index a81705008..44547cbfa 100644 --- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md +++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md @@ -9372,7 +9372,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:184:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:190:5` (problem-constructor) @@ -9390,7 +9390,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:212:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:218:5` (problem-constructor) @@ -9408,7 +9408,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:222:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:228:5` (problem-constructor) @@ -9444,7 +9444,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:233:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:239:5` (problem-constructor) @@ -9462,7 +9462,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:249:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:255:5` (problem-constructor) @@ -9480,7 +9480,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:262:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:268:5` (problem-constructor) @@ -9498,7 +9498,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:171:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:177:5` (problem-constructor) diff --git a/packages/problems-core/src/generated/problem-code-registry.ts b/packages/problems-core/src/generated/problem-code-registry.ts index 80f24d52b..9ed68e834 100644 --- a/packages/problems-core/src/generated/problem-code-registry.ts +++ b/packages/problems-core/src/generated/problem-code-registry.ts @@ -15253,7 +15253,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 184, + line: 190, column: 5, kind: "problem-constructor", }, @@ -15285,7 +15285,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 212, + line: 218, column: 5, kind: "problem-constructor", }, @@ -15317,7 +15317,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 222, + line: 228, column: 5, kind: "problem-constructor", }, @@ -15381,7 +15381,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 233, + line: 239, column: 5, kind: "problem-constructor", }, @@ -15413,7 +15413,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 249, + line: 255, column: 5, kind: "problem-constructor", }, @@ -15446,7 +15446,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 262, + line: 268, column: 5, kind: "problem-constructor", }, @@ -15478,7 +15478,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 171, + line: 177, column: 5, kind: "problem-constructor", }, diff --git a/packages/ratelimit-core/src/index.ts b/packages/ratelimit-core/src/index.ts index 40d0584c5..0c739caa6 100644 --- a/packages/ratelimit-core/src/index.ts +++ b/packages/ratelimit-core/src/index.ts @@ -25,7 +25,6 @@ export { */ export { FixedWindowInMemoryStore, - type InMemoryRateLimitStoreOptions, SlidingWindowInMemoryStore, TokenBucketInMemoryStore, } from "./libs/InMemoryRateLimitStore"; @@ -112,3 +111,8 @@ export { type TokenBucketRefundReceipt, type TokenBucketPolicy, } from "./libs/types"; + +export type { + InMemoryRateLimitStoreOptions, + RateLimitPruneScheduler, +} from "./libs/InMemoryRateLimitStore"; diff --git a/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts b/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts index 195b81f68..c185b4a00 100644 --- a/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts +++ b/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts @@ -14,10 +14,25 @@ export type InMemoryRateLimitStoreOptions = { readonly now?: () => number; readonly pruneIntervalMs?: number; readonly random?: () => number; + readonly scheduler?: RateLimitPruneScheduler; +}; + +export type RateLimitPruneScheduler = { + schedule(callback: () => void | Promise, intervalMs: number): () => void; }; const DEFAULT_PRUNE_INTERVAL_MS = 60000; +const DEFAULT_PRUNE_SCHEDULER: RateLimitPruneScheduler = { + schedule(callback, intervalMs) { + const timer = setTimeout(() => { + void callback(); + }, intervalMs); + timer.unref?.(); + return () => clearTimeout(timer); + }, +}; + type MutableRateLimitStats = { allowed: number; denied: number; @@ -29,30 +44,46 @@ function recordRefund(stats: MutableRateLimitStats): void { stats.total = Math.max(0, stats.total - 1); } +function schedulePruning( + options: InMemoryRateLimitStoreOptions, + callback: () => void | Promise, +): (() => void) | undefined { + const pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS; + if (pruneIntervalMs <= 0) return undefined; + const scheduler = options.scheduler ?? DEFAULT_PRUNE_SCHEDULER; + let cancelScheduledWork: (() => void) | undefined; + let closed = false; + const scheduleNext = () => { + cancelScheduledWork = scheduler.schedule(async () => { + await callback(); + if (!closed) scheduleNext(); + }, pruneIntervalMs); + }; + scheduleNext(); + return () => { + closed = true; + cancelScheduledWork?.(); + }; +} + export class FixedWindowInMemoryStore extends FixedWindowStore { private readonly windows = new Map< string, { count: number; windowStart: number; windowMs: number } >(); private readonly globalStats = { allowed: 0, denied: 0, total: 0 }; - private readonly pruneTimer?: ReturnType; + private readonly cancelPruning?: () => void; constructor(options: InMemoryRateLimitStoreOptions = {}) { super(options.now, options.random); - const pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS; - if (pruneIntervalMs > 0) { - this.pruneTimer = setInterval(() => { - void this.pruneExpired(); - }, pruneIntervalMs); - this.pruneTimer.unref?.(); - } + this.cancelPruning = schedulePruning(options, async () => { + await this.pruneExpired(); + }); } close(): void { - if (this.pruneTimer !== undefined) { - clearInterval(this.pruneTimer); - } + this.cancelPruning?.(); } destroy(): void { @@ -156,24 +187,18 @@ export class SlidingWindowInMemoryStore extends SlidingWindowStore { >(); private readonly _windowMsCache = new Map(); private readonly globalStats = { allowed: 0, denied: 0, total: 0 }; - private readonly pruneTimer?: ReturnType; + private readonly cancelPruning?: () => void; constructor(options: InMemoryRateLimitStoreOptions = {}) { super(options.now, options.random); - const pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS; - if (pruneIntervalMs > 0) { - this.pruneTimer = setInterval(() => { - void this.pruneExpired(); - }, pruneIntervalMs); - this.pruneTimer.unref?.(); - } + this.cancelPruning = schedulePruning(options, async () => { + await this.pruneExpired(); + }); } close(): void { - if (this.pruneTimer !== undefined) { - clearInterval(this.pruneTimer); - } + this.cancelPruning?.(); } destroy(): void { @@ -340,24 +365,18 @@ export class TokenBucketInMemoryStore extends TokenBucketStore { { tokens: number; lastRefill: number; ttlMs: number } >(); private readonly globalStats = { allowed: 0, denied: 0, total: 0 }; - private readonly pruneTimer?: ReturnType; + private readonly cancelPruning?: () => void; constructor(options: InMemoryRateLimitStoreOptions = {}) { super(options.now, options.random); - const pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS; - if (pruneIntervalMs > 0) { - this.pruneTimer = setInterval(() => { - void this.pruneExpired(); - }, pruneIntervalMs); - this.pruneTimer.unref?.(); - } + this.cancelPruning = schedulePruning(options, async () => { + await this.pruneExpired(); + }); } close(): void { - if (this.pruneTimer !== undefined) { - clearInterval(this.pruneTimer); - } + this.cancelPruning?.(); } destroy(): void { diff --git a/packages/tasks-core/src/index.ts b/packages/tasks-core/src/index.ts index 3235ff060..f8f2a4c41 100644 --- a/packages/tasks-core/src/index.ts +++ b/packages/tasks-core/src/index.ts @@ -26,7 +26,7 @@ export { TaskRegistry } from "./libs/TaskRegistry"; /** * 등록된 태스크를 실행 시스템과 연결해 실행하는 러너입니다. */ -export { TaskRunner, type TaskRunnerRuntime } from "./libs/TaskRunner"; +export { TaskRunner } from "./libs/TaskRunner"; /** * 태스크 선언과 식별에 사용하는 공개 타입들입니다. @@ -38,3 +38,5 @@ export type { TaskOptions, TaskReference, } from "./libs/types"; + +export type { TaskRunnerRuntime } from "./libs/TaskRunner"; diff --git a/packages/tasks-core/src/tests/TaskRunner.spec.ts b/packages/tasks-core/src/tests/TaskRunner.spec.ts index b359a3a3b..238cdb7e0 100644 --- a/packages/tasks-core/src/tests/TaskRunner.spec.ts +++ b/packages/tasks-core/src/tests/TaskRunner.spec.ts @@ -639,6 +639,7 @@ describe("TaskRunner", () => { it("should use injected time and scheduling boundaries for deterministic timeouts", async () => { let now = new Date("2026-01-01T00:00:00.000Z").getTime(); let scheduledTimeout: (() => void) | undefined; + let scheduledDelayMs: number | undefined; let receivedContext: TaskExecutionContext | undefined; @Component() @@ -668,8 +669,9 @@ describe("TaskRunner", () => { ); const runner = new TaskRunner(mockExecutionManager, registry, undefined, { now: () => now, - schedule: (callback) => { + schedule: (callback, delayMs) => { scheduledTimeout = callback; + scheduledDelayMs = delayMs; return () => { scheduledTimeout = undefined; }; @@ -678,10 +680,11 @@ describe("TaskRunner", () => { const result = runner.execute("controlled-timed-task", {}); await vi.waitFor(() => expect(scheduledTimeout).toBeDefined()); + expect(scheduledDelayMs).toBe(100); now += 100; scheduledTimeout?.(); - await expect(result).rejects.toBeInstanceOf(TaskExecutionTimeoutProblem); + await expect(result).rejects.toThrow(TaskExecutionTimeoutProblem); expect(receivedContext?.signal.aborted).toBe(true); expect(mockExecutionManager.timeout).toHaveBeenCalledWith("exec-123"); }); diff --git a/packages/testing/src/libs/TestKernel.ts b/packages/testing/src/libs/TestKernel.ts index 07e64ab46..fa4115e9e 100644 --- a/packages/testing/src/libs/TestKernel.ts +++ b/packages/testing/src/libs/TestKernel.ts @@ -63,10 +63,16 @@ export type TestKernelLeak = { | "after-commit" | "event-handler" | "operation" + | "operation-failure" | "resource" | "scheduled-work" | "span" | "wait-until"; + readonly failure?: { + readonly code?: string; + readonly message: string; + readonly name: string; + }; readonly source: string; }; @@ -317,6 +323,7 @@ export class TestKernel implements AsyncDisposable { private disposed = false; private readonly evidenceBuffer: TestKernelEvidence[] = []; private readonly inFlight = new Map, TestKernelTrackedWork>(); + private readonly trackedFailures: Array<{ error: Error; work: TestKernelTrackedWork }> = []; constructor( readonly app: CrocoApp, @@ -470,6 +477,15 @@ export class TestKernel implements AsyncDisposable { private collectLeaks(): TestKernelLeak[] { const operationLeaks = [...this.inFlight.values()]; + const operationFailures = this.trackedFailures.map(({ error, work }) => ({ + category: "operation-failure" as const, + failure: { + ...(error instanceof Problem ? { code: error.code } : {}), + message: error.message, + name: error.name, + }, + source: work.source, + })); const scheduledLeaks = this.clock.pendingWork.map((work: TestScheduledWork) => ({ category: "scheduled-work" as const, source: work.source, @@ -479,14 +495,17 @@ export class TestKernel implements AsyncDisposable { category: "after-commit" as const, source: "transaction-context", })); - return [...operationLeaks, ...scheduledLeaks, ...afterCommitLeaks]; + return [...operationLeaks, ...operationFailures, ...scheduledLeaks, ...afterCommitLeaks]; } private trackInFlight(operation: Promise, work: TestKernelTrackedWork): Promise { this.inFlight.set(operation, work); void operation.then( () => this.inFlight.delete(operation), - () => this.inFlight.delete(operation), + (error: unknown) => { + this.inFlight.delete(operation); + this.trackedFailures.push({ error: toError(error), work }); + }, ); return operation; } diff --git a/packages/testing/src/libs/TestRuntime.ts b/packages/testing/src/libs/TestRuntime.ts index 555a4b382..60132d13a 100644 --- a/packages/testing/src/libs/TestRuntime.ts +++ b/packages/testing/src/libs/TestRuntime.ts @@ -190,10 +190,16 @@ export class TestEnvironment { export class TestNetwork { constructor(private readonly mode: "allow" | "deny") {} - fetch(input: string | URL | Request, init?: RequestInit): Promise { + async fetch(input: string | URL | Request, init?: RequestInit): Promise { const url = input instanceof Request ? input.url : input.toString(); if (this.mode === "deny") { - throw new TestKernelOutboundCallProblem(new URL(url).host); + let host: string; + try { + host = new URL(url).host; + } catch { + throw new TestKernelOutboundCallProblem(url); + } + throw new TestKernelOutboundCallProblem(host); } return fetch(input, init); } diff --git a/packages/testing/src/tests/TestKernel.spec.ts b/packages/testing/src/tests/TestKernel.spec.ts index 11f5a8642..e5c403635 100644 --- a/packages/testing/src/tests/TestKernel.spec.ts +++ b/packages/testing/src/tests/TestKernel.spec.ts @@ -591,8 +591,8 @@ describe("TestKernel", () => { const secondDisposal = kernel.dispose(); expect(firstDisposal).toBe(secondDisposal); - await expect(firstDisposal).rejects.toBeInstanceOf(TestKernelDisposalProblem); - await expect(secondDisposal).rejects.toBeInstanceOf(TestKernelDisposalProblem); + await expect(firstDisposal).rejects.toThrow(TestKernelDisposalProblem); + await expect(secondDisposal).rejects.toThrow(TestKernelDisposalProblem); expect(cleanupCalls).toBe(1); expect(fallbackCleanupCalls).toBe(1); }); @@ -626,10 +626,39 @@ describe("TestKernel", () => { kernel.waitUntil(new Promise(() => undefined), "response.flush"); - await expect(kernel.dispose()).rejects.toBeInstanceOf(TestKernelDisposalProblem); + await expect(kernel.dispose()).rejects.toThrow(TestKernelDisposalProblem); expect(cleanupCalls).toBe(1); }); + it("preserves rejected tracked work as leak evidence", async () => { + const kernel = await createTestKernel({ + bootstrap: () => bootstrapProductionApp("tracked-rejection"), + fidelity: "application", + }); + + kernel.waitUntil(Promise.reject(new Error("response flush failed")), "response.flush"); + await Promise.resolve(); + + let error: unknown; + try { + kernel.expectClean(); + } catch (caught) { + error = caught; + } + expect(error).toMatchObject({ + extensions: { + leaks: expect.arrayContaining([ + { + category: "operation-failure", + failure: { message: "response flush failed", name: "Error" }, + source: "response.flush", + }, + ]), + }, + }); + await expect(kernel.dispose()).rejects.toThrow(TestKernelDisposalProblem); + }); + it("isolates and executes production shutdown hooks for each kernel", async () => { let firstShutdowns = 0; let secondShutdowns = 0; @@ -680,7 +709,7 @@ describe("TestKernel", () => { fidelity: "application", }); - await expect(kernel.dispose()).rejects.toBeInstanceOf(TestKernelDisposalProblem); + await expect(kernel.dispose()).rejects.toThrow(TestKernelDisposalProblem); }); it("injects deterministic runtime controls and reports replay metadata", async () => { @@ -811,6 +840,6 @@ describe("TestKernel", () => { ]), }, }); - await expect(kernel.dispose()).rejects.toBeInstanceOf(TestKernelDisposalProblem); + await expect(kernel.dispose()).rejects.toThrow(TestKernelDisposalProblem); }); }); diff --git a/packages/testing/src/tests/TestRuntime.spec.ts b/packages/testing/src/tests/TestRuntime.spec.ts index 40d1573b8..1f8b70266 100644 --- a/packages/testing/src/tests/TestRuntime.spec.ts +++ b/packages/testing/src/tests/TestRuntime.spec.ts @@ -76,31 +76,31 @@ describe("TestRuntime", () => { ); }); - it("rejects outbound calls by default with a provider-facing diagnostic", () => { + it("rejects outbound calls by default with a provider-facing diagnostic", async () => { const runtime = new TestRuntime({ network: "deny" }); - expect(() => runtime.network.fetch("https://provider.example.test/v1/send")).toThrow( - TestKernelOutboundCallProblem, - ); - try { - runtime.network.fetch("https://provider.example.test/v1/send"); - } catch (error) { - expect(error).toMatchObject({ - code: "testing/test-kernel-outbound-call", - extensions: { - host: "provider.example.test", - recovery: "Register a provider fake or explicitly allow this outbound call.", - }, - }); - } + await expect( + runtime.network.fetch("https://provider.example.test/v1/send"), + ).rejects.toMatchObject({ + code: "testing/test-kernel-outbound-call", + extensions: { + host: "provider.example.test", + recovery: "Register a provider fake or explicitly allow this outbound call.", + }, + }); + await expect(runtime.network.fetch("not a url")).rejects.toThrow(TestKernelOutboundCallProblem); }); it("supplies virtual time and seeded entropy to rate-limit boundaries without global timers", async () => { const runtime = new TestRuntime({ ids: "rate-limit", network: "deny" }); const store = new SlidingWindowInMemoryStore({ now: () => runtime.clock.now.getTime(), - pruneIntervalMs: 0, + pruneIntervalMs: 30_000, random: () => runtime.random.next(), + scheduler: { + schedule: (callback, delayMs) => + runtime.clock.schedule(callback, delayMs, "rate-limit:prune"), + }, }); const policy = createSlidingWindowPolicy("test-runtime", 1, 30_000); @@ -112,10 +112,14 @@ describe("TestRuntime", () => { await runtime.clock.advanceBy(30_001); + expect(runtime.clock.pendingWork).toMatchObject([{ source: "rate-limit:prune" }]); + await expect(store.check("tenant:one", policy)).resolves.toMatchObject({ remaining: 0, success: true, }); + store.close(); + expect(runtime.clock.pendingWork).toEqual([]); }); it("drains retry-core backoff through kernel-owned virtual time", async () => { diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index e5923a822..01fb0bf01 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -22358,6 +22358,11 @@ "exportKind": "named", "source": "./libs/types" }, + { + "name": "RateLimitPruneScheduler", + "exportKind": "named", + "source": "./libs/InMemoryRateLimitStore" + }, { "name": "RateLimitRefundReceipt", "exportKind": "named", From b5593e60107f6cb38ac87400fe6759e77fe062ed Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 2 Aug 2026 08:44:19 +0900 Subject: [PATCH 4/9] fix: keep deterministic runtime documentation formatted --- packages/testing/README.md | 68 +++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/testing/README.md b/packages/testing/README.md index 6cc882875..9185d9584 100644 --- a/packages/testing/README.md +++ b/packages/testing/README.md @@ -46,41 +46,41 @@ test.expectClean(); ## API -| Helper | Purpose | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `createTestingApp(config)` | Creates an isolated `CrocoApp` with seeded test defaults and HTTP request helpers. | -| `createTestKernel(config)` | Boots the real application bootstrap inside an isolated runtime scope and reports application or adapter fidelity as structured evidence. | +| Helper | Purpose | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `createTestingApp(config)` | Creates an isolated `CrocoApp` with seeded test defaults and HTTP request helpers. | +| `createTestKernel(config)` | Boots the real application bootstrap inside an isolated runtime scope and reports application or adapter fidelity as structured evidence. | | `fixedClock(initial)` / `seededIds(seed)` | Creates virtual time and deterministic IDs/random values for TestKernel-controlled retry, timeout, rate-limit, task, and provider fixtures. | -| `createTestingHarness(app)` | Wraps an existing `CrocoApp` with the same request and contract helpers. | -| `createEventTestingHarness(config)` | Creates an isolated in-memory event bus and dispatches decorated handlers. | -| `createTestingRequestContext(config)` | Builds a deterministic request/runtime context for service tests. | -| `runWithTestingContext(fn, config)` | Runs code inside Croco `Context` and clears AsyncLocalStorage state when execution completes. | -| `createTestingTransactionContext(config)` | Provides explicit in-transaction and after-commit hook behavior for tests. | -| `resetCrocoTestingContext()` | Resets the Croco DI container and seeds test logger/error/health defaults. | -| `installTestingTelemetryCapture()` | Captures spans in memory without starting an SDK exporter. | -| `createFailureDrillCatalog()` | Builds deterministic no-credential failure drills for provider timeout, duplicate delivery, outbox, telemetry, tenant, and quota failures. | -| `runFailureDrills(cases)` | Executes failure drills and rejects runs that lack the expected Problem code, recovery action, telemetry evidence, or audit evidence. | -| `createOperationalFailureDrillMatrix(cases)` | Validates the exact ordered operational incident matrix without changing the generic six-scenario catalog. | -| `runOperationalFailureDrills(cases)` | Executes operational fixtures and verifies their Problem or diagnostic outcome, recovery action, and real-boundary provenance. | -| `assertProblemResponse(response, expected)` | Verifies an RFC 7807 Problem Details response without depending on a test runner. | -| `assertOpenAPIRoute(controllersOrSpec, expected)` | Verifies generated OpenAPI route metadata and response contracts. | -| `createRpcTestFetch(app)` | Returns a fetch-compatible function that routes generated RPC clients into the in-memory app. | -| `createAuthProviderConformanceSuite(config)` | Reusable auth provider cases for token/session auth, webhooks, tenant mapping, readiness, and live-smoke gating. | -| `createStorageProviderConformanceSuite(config)` | Reusable storage provider contract cases for default no-credential CI. | -| `createProviderConformanceMatrixSuite(config)` | Validates provider profile manifests for required capabilities, optional unsupported reasons, and method evidence. | -| `createLlmProviderConformanceSuite(config)` | Reusable LLM provider contract cases for mocked or live provider fixtures. | -| `createBillingProviderConformanceSuite(config)` | Builds runner-neutral billing gateway and webhook conformance cases for provider packages. | -| `createUpstashRedisMeteringConformanceSuite(config)` | Reusable Upstash Redis metering cases for config, usage storage, idempotency, upstream errors, and live-smoke gating. | -| `createUpstashRedisRateLimitConformanceSuite(config)` | Reusable Upstash Redis rate-limit cases for config, errors, refund idempotency, and live-smoke gating. | -| `createQStashTaskConformanceSuite(config)` | Reusable QStash task publish cases for config, validation, idempotency, upstream errors, and live-smoke gating. | -| `createQStashBatchConformanceSuite(config)` | Reusable QStash batch chunk cases for terminal chunks, continuation envelopes, upstream errors, and live-smoke gating. | -| `createQStashTriggerConformanceSuite(config)` | Reusable QStash trigger cases for schedule sync, webhook verification, dispatch, upstream diagnostics, and live-smoke gating. | -| `createDrizzleProviderConformanceSuite(config)` | Builds reusable Drizzle provider cases for schema, transaction, tenant, and error contracts. | -| `assertDrizzleProblem(operation, expected)` | Verifies Drizzle provider failures surface stable Croco Problem codes, categories, or status. | -| `createTestEvidenceRecord(input)` | Builds validated `croco.test-evidence/v1` records and derives flaky outcomes from retained attempts. | -| `createTestEvidenceBundle(records, artifactExists)` | Deterministically aggregates runner-neutral evidence and reports every missing required attachment. | -| `CrocoVitestEvidenceReporter` | Adapts Vitest results and retries into the common evidence model without replacing Vitest. | -| `CrocoPlaywrightEvidenceReporter` | Adapts Playwright attempts, traces, screenshots, and reports without replacing Playwright. | +| `createTestingHarness(app)` | Wraps an existing `CrocoApp` with the same request and contract helpers. | +| `createEventTestingHarness(config)` | Creates an isolated in-memory event bus and dispatches decorated handlers. | +| `createTestingRequestContext(config)` | Builds a deterministic request/runtime context for service tests. | +| `runWithTestingContext(fn, config)` | Runs code inside Croco `Context` and clears AsyncLocalStorage state when execution completes. | +| `createTestingTransactionContext(config)` | Provides explicit in-transaction and after-commit hook behavior for tests. | +| `resetCrocoTestingContext()` | Resets the Croco DI container and seeds test logger/error/health defaults. | +| `installTestingTelemetryCapture()` | Captures spans in memory without starting an SDK exporter. | +| `createFailureDrillCatalog()` | Builds deterministic no-credential failure drills for provider timeout, duplicate delivery, outbox, telemetry, tenant, and quota failures. | +| `runFailureDrills(cases)` | Executes failure drills and rejects runs that lack the expected Problem code, recovery action, telemetry evidence, or audit evidence. | +| `createOperationalFailureDrillMatrix(cases)` | Validates the exact ordered operational incident matrix without changing the generic six-scenario catalog. | +| `runOperationalFailureDrills(cases)` | Executes operational fixtures and verifies their Problem or diagnostic outcome, recovery action, and real-boundary provenance. | +| `assertProblemResponse(response, expected)` | Verifies an RFC 7807 Problem Details response without depending on a test runner. | +| `assertOpenAPIRoute(controllersOrSpec, expected)` | Verifies generated OpenAPI route metadata and response contracts. | +| `createRpcTestFetch(app)` | Returns a fetch-compatible function that routes generated RPC clients into the in-memory app. | +| `createAuthProviderConformanceSuite(config)` | Reusable auth provider cases for token/session auth, webhooks, tenant mapping, readiness, and live-smoke gating. | +| `createStorageProviderConformanceSuite(config)` | Reusable storage provider contract cases for default no-credential CI. | +| `createProviderConformanceMatrixSuite(config)` | Validates provider profile manifests for required capabilities, optional unsupported reasons, and method evidence. | +| `createLlmProviderConformanceSuite(config)` | Reusable LLM provider contract cases for mocked or live provider fixtures. | +| `createBillingProviderConformanceSuite(config)` | Builds runner-neutral billing gateway and webhook conformance cases for provider packages. | +| `createUpstashRedisMeteringConformanceSuite(config)` | Reusable Upstash Redis metering cases for config, usage storage, idempotency, upstream errors, and live-smoke gating. | +| `createUpstashRedisRateLimitConformanceSuite(config)` | Reusable Upstash Redis rate-limit cases for config, errors, refund idempotency, and live-smoke gating. | +| `createQStashTaskConformanceSuite(config)` | Reusable QStash task publish cases for config, validation, idempotency, upstream errors, and live-smoke gating. | +| `createQStashBatchConformanceSuite(config)` | Reusable QStash batch chunk cases for terminal chunks, continuation envelopes, upstream errors, and live-smoke gating. | +| `createQStashTriggerConformanceSuite(config)` | Reusable QStash trigger cases for schedule sync, webhook verification, dispatch, upstream diagnostics, and live-smoke gating. | +| `createDrizzleProviderConformanceSuite(config)` | Builds reusable Drizzle provider cases for schema, transaction, tenant, and error contracts. | +| `assertDrizzleProblem(operation, expected)` | Verifies Drizzle provider failures surface stable Croco Problem codes, categories, or status. | +| `createTestEvidenceRecord(input)` | Builds validated `croco.test-evidence/v1` records and derives flaky outcomes from retained attempts. | +| `createTestEvidenceBundle(records, artifactExists)` | Deterministically aggregates runner-neutral evidence and reports every missing required attachment. | +| `CrocoVitestEvidenceReporter` | Adapts Vitest results and retries into the common evidence model without replacing Vitest. | +| `CrocoPlaywrightEvidenceReporter` | Adapts Playwright attempts, traces, screenshots, and reports without replacing Playwright. | ## Isolation Contract From 1f56be6cbeaafea348c31144dcf8d96427468925 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 2 Aug 2026 09:04:40 +0900 Subject: [PATCH 5/9] fix: make deterministic test scheduling bounded --- docs/problem-code-registry.json | 48 ++++++++++++++---- .../en/reference/problem-recovery-cookbook.md | 37 ++++++++++---- .../src/generated/problem-code-registry.ts | 50 +++++++++++++++---- .../src/libs/InMemoryRateLimitStore.ts | 4 +- packages/testing/src/index.ts | 1 + packages/testing/src/libs/TestKernel.ts | 10 ++-- packages/testing/src/libs/TestRuntime.ts | 36 +++++++++++-- .../testing/src/tests/TestRuntime.spec.ts | 39 +++++++++++++++ public-api-surface.snapshot.json | 6 +++ 9 files changed, 195 insertions(+), 36 deletions(-) diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json index 943fc0d98..16438ea69 100644 --- a/docs/problem-code-registry.json +++ b/docs/problem-code-registry.json @@ -14636,7 +14636,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 190, + "line": 189, "column": 5, "kind": "problem-constructor" } @@ -14666,7 +14666,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 218, + "line": 217, "column": 5, "kind": "problem-constructor" } @@ -14696,7 +14696,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 228, + "line": 227, "column": 5, "kind": "problem-constructor" } @@ -14726,7 +14726,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestRuntime.ts", - "line": 41, + "line": 44, "column": 5, "kind": "problem-constructor" } @@ -14756,7 +14756,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 239, + "line": 238, "column": 5, "kind": "problem-constructor" } @@ -14786,7 +14786,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 255, + "line": 254, "column": 5, "kind": "problem-constructor" } @@ -14816,7 +14816,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 268, + "line": 267, "column": 5, "kind": "problem-constructor" } @@ -14846,7 +14846,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestKernel.ts", - "line": 177, + "line": 176, "column": 5, "kind": "problem-constructor" } @@ -14876,7 +14876,37 @@ "sources": [ { "file": "packages/testing/src/libs/TestRuntime.ts", - "line": 57, + "line": 60, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "testing/test-runtime-drain-limit", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#testing-test-runtime-drain-limit", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "lifecycle": { + "status": "active" + }, + "sources": [ + { + "file": "packages/testing/src/libs/TestRuntime.ts", + "line": 71, "column": 5, "kind": "problem-constructor" } diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md index 44547cbfa..70be5d7c5 100644 --- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md +++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md @@ -509,6 +509,7 @@ This cookbook documents 571 public Croco Problem codes. The deterministic JSON r | [`testing/test-kernel-resource-registration`](#testing-test-kernel-resource-registration) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`testing/test-kernel-validation-policy`](#testing-test-kernel-validation-policy) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`testing/test-runtime-configuration`](#testing-test-runtime-configuration) | ValidationError | 422 | not-retryable | public | active | 1 | +| [`testing/test-runtime-drain-limit`](#testing-test-runtime-drain-limit) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`testing/transaction-context-not-active`](#testing-transaction-context-not-active) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`TOKEN_LIMIT_EXCEEDED`](#token-limit-exceeded) | BadRequest | 400 | not-retryable | public | active | 1 | | [`TOOL_EXECUTION_ERROR`](#tool-execution-error) | InternalServerError | 500 | conditional | operator-only | active | 1 | @@ -9372,7 +9373,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:190:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:189:5` (problem-constructor) @@ -9390,7 +9391,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:218:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:217:5` (problem-constructor) @@ -9408,7 +9409,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:228:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:227:5` (problem-constructor) @@ -9426,7 +9427,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestRuntime.ts:41:5` (problem-constructor) +- `packages/testing/src/libs/TestRuntime.ts:44:5` (problem-constructor) @@ -9444,7 +9445,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:239:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:238:5` (problem-constructor) @@ -9462,7 +9463,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:255:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:254:5` (problem-constructor) @@ -9480,7 +9481,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:268:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:267:5` (problem-constructor) @@ -9498,7 +9499,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestKernel.ts:177:5` (problem-constructor) +- `packages/testing/src/libs/TestKernel.ts:176:5` (problem-constructor) @@ -9516,7 +9517,25 @@ Sources: Sources: -- `packages/testing/src/libs/TestRuntime.ts:57:5` (problem-constructor) +- `packages/testing/src/libs/TestRuntime.ts:60:5` (problem-constructor) + + + +## `testing/test-runtime-drain-limit` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Lifecycle: `active` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/testing/src/libs/TestRuntime.ts:71:5` (problem-constructor) diff --git a/packages/problems-core/src/generated/problem-code-registry.ts b/packages/problems-core/src/generated/problem-code-registry.ts index 9ed68e834..5ec95aa21 100644 --- a/packages/problems-core/src/generated/problem-code-registry.ts +++ b/packages/problems-core/src/generated/problem-code-registry.ts @@ -15253,7 +15253,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 190, + line: 189, column: 5, kind: "problem-constructor", }, @@ -15285,7 +15285,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 218, + line: 217, column: 5, kind: "problem-constructor", }, @@ -15317,7 +15317,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 228, + line: 227, column: 5, kind: "problem-constructor", }, @@ -15349,7 +15349,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestRuntime.ts", - line: 41, + line: 44, column: 5, kind: "problem-constructor", }, @@ -15381,7 +15381,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 239, + line: 238, column: 5, kind: "problem-constructor", }, @@ -15413,7 +15413,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 255, + line: 254, column: 5, kind: "problem-constructor", }, @@ -15446,7 +15446,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 268, + line: 267, column: 5, kind: "problem-constructor", }, @@ -15478,7 +15478,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestKernel.ts", - line: 177, + line: 176, column: 5, kind: "problem-constructor", }, @@ -15508,7 +15508,39 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestRuntime.ts", - line: 57, + line: 60, + column: 5, + kind: "problem-constructor", + }, + ], + }, + { + code: "testing/test-runtime-drain-limit", + category: "InternalServerError", + status: 500, + title: "Internal Server Error", + cookbookPath: "/reference/problem-recovery-cookbook/#testing-test-runtime-drain-limit", + recovery: { + cause: "Croco or an upstream dependency failed after accepting the request.", + userAction: + "Retry later only when the operation is idempotent or the caller owns retry safety.", + operatorAction: + "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + retryability: "conditional", + redactionPolicy: "operator-only", + telemetry: { + eventName: "croco.problem.error", + severity: "error", + attributes: ["problem.code", "problem.category", "problem.status"], + }, + }, + lifecycle: { + status: "active", + }, + sources: [ + { + file: "packages/testing/src/libs/TestRuntime.ts", + line: 71, column: 5, kind: "problem-constructor", }, diff --git a/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts b/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts index c185b4a00..63b079ef3 100644 --- a/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts +++ b/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts @@ -17,9 +17,9 @@ export type InMemoryRateLimitStoreOptions = { readonly scheduler?: RateLimitPruneScheduler; }; -export type RateLimitPruneScheduler = { +export interface RateLimitPruneScheduler { schedule(callback: () => void | Promise, intervalMs: number): () => void; -}; +} const DEFAULT_PRUNE_INTERVAL_MS = 60000; diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index 0fefe34f4..779c9d36c 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -113,6 +113,7 @@ export { fixedClock, seededIds, TestClock, + TestRuntimeDrainProblem, TestEnvironment, TestIdSource, TestKernelOutboundCallProblem, diff --git a/packages/testing/src/libs/TestKernel.ts b/packages/testing/src/libs/TestKernel.ts index fa4115e9e..f560ff13a 100644 --- a/packages/testing/src/libs/TestKernel.ts +++ b/packages/testing/src/libs/TestKernel.ts @@ -20,11 +20,10 @@ import { type TestingRequestOptions, type TestingTransactionContext, } from "./testing"; -import { TestRuntime } from "./TestRuntime"; +import { TestIdSource, TestRuntime } from "./TestRuntime"; import type { TestClock, TestEnvironment, - TestIdSource, TestNetwork, TestRandomSource, TestEnvironmentOverrides, @@ -517,7 +516,12 @@ export async function createTestKernel(options: TestKernelOptions): Promise left.dueAtMs - right.dueAtMs || left.id.localeCompare(right.id)) + .sort((left, right) => left.dueAtMs - right.dueAtMs || left.sequence - right.sequence) .map(({ dueAtMs, id, source }) => ({ dueAt: new Date(dueAtMs).toISOString(), id, source })); } @@ -91,11 +111,13 @@ export class TestClock { delay: TestDuration, source = "scheduled-work", ): () => void { - const id = `scheduled-${++this.scheduledSequence}`; + const sequence = ++this.scheduledSequence; + const id = `scheduled-${sequence}`; this.scheduled.set(id, { callback, dueAtMs: this.currentTimeMs + parseDuration(delay), id, + sequence, source, }); return () => this.scheduled.delete(id); @@ -122,10 +144,16 @@ export class TestClock { } private async drainUntil(targetTime: number): Promise { + let callbackCount = 0; while (true) { const next = this.nextScheduled(); if (!next || next.dueAtMs > targetTime) return; + callbackCount += 1; + if (callbackCount > MAX_DRAIN_CALLBACKS) { + throw new TestRuntimeDrainProblem(MAX_DRAIN_CALLBACKS); + } + this.scheduled.delete(next.id); this.currentTimeMs = next.dueAtMs; await next.callback(); @@ -134,7 +162,7 @@ export class TestClock { private nextScheduled(): ScheduledEntry | undefined { return [...this.scheduled.values()].sort( - (left, right) => left.dueAtMs - right.dueAtMs || left.id.localeCompare(right.id), + (left, right) => left.dueAtMs - right.dueAtMs || left.sequence - right.sequence, )[0]; } } @@ -218,7 +246,7 @@ export class TestRuntime { typeof options.ids === "string" ? new TestIdSource(options.ids) : options.ids - ? new TestIdSource(options.ids.seed) + ? options.ids : new TestIdSource(crypto.randomUUID()); this.clock = options.clock instanceof TestClock diff --git a/packages/testing/src/tests/TestRuntime.spec.ts b/packages/testing/src/tests/TestRuntime.spec.ts index 1f8b70266..732b5a1c9 100644 --- a/packages/testing/src/tests/TestRuntime.spec.ts +++ b/packages/testing/src/tests/TestRuntime.spec.ts @@ -7,6 +7,7 @@ import { TestKernelOutboundCallProblem, TestRuntime, TestRuntimeConfigurationProblem, + TestRuntimeDrainProblem, } from "../index"; describe("TestRuntime", () => { @@ -48,9 +49,42 @@ describe("TestRuntime", () => { ]); }); + it("preserves insertion order for work scheduled at the same virtual time", async () => { + const clock = fixedClock("2026-01-01T00:00:00.000Z"); + const events: string[] = []; + + for (let index = 0; index < 12; index += 1) { + clock.schedule( + () => { + events.push(String(index)); + }, + 0, + "same-time", + ); + } + + await clock.drain(); + + expect(events).toEqual(Array.from({ length: 12 }, (_, index) => String(index))); + }); + + it("reports non-terminating same-time rescheduling with a stable Problem", async () => { + const clock = fixedClock("2026-01-01T00:00:00.000Z"); + const reschedule = () => { + clock.schedule(reschedule, 0, "same-time-loop"); + }; + clock.schedule(reschedule, 0, "same-time-loop"); + + await expect(clock.drain()).rejects.toThrow(TestRuntimeDrainProblem); + }); + it("replays seeded ids and random values without touching process state", () => { const first = seededIds("invitation-retry"); const second = seededIds("invitation-retry"); + const advancedIds = seededIds("invitation-retry"); + advancedIds.next("already-used"); + const expectedIds = seededIds("invitation-retry"); + expectedIds.next("already-used"); const environmentBefore = process.env["CROCO_TEST_RUNTIME_FIXTURE"]; const runtime = new TestRuntime({ environment: { CROCO_TEST_RUNTIME_FIXTURE: "scoped" }, @@ -67,6 +101,11 @@ describe("TestRuntime", () => { seed: "invitation-retry", virtualTime: "2026-01-01T00:00:00.000Z", }); + + const runtimeWithAdvancedIds = new TestRuntime({ ids: advancedIds }); + + expect(runtimeWithAdvancedIds.scenarioId).toBe(expectedIds.next("scenario")); + expect(runtimeWithAdvancedIds.ids).toBe(advancedIds); }); it("reports invalid time controls with a stable Problem code", () => { diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index 01fb0bf01..c3db69a62 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -25756,6 +25756,12 @@ "exportKind": "named", "source": "./libs/TestRuntime", "declarationKind": "class" + }, + { + "name": "TestRuntimeDrainProblem", + "exportKind": "named", + "source": "./libs/TestRuntime", + "declarationKind": "class" } ], "typeExports": [ From 6b18891d6a0b9866d82c5c44d686ba80df98cd13 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Mon, 3 Aug 2026 00:13:03 +0900 Subject: [PATCH 6/9] fix: preserve deterministic test runtime state --- docs/problem-code-registry.json | 2 +- .../en/reference/problem-recovery-cookbook.md | 2 +- .../src/generated/problem-code-registry.ts | 2 +- packages/tasks-core/src/libs/TaskRunner.ts | 4 ++-- .../tasks-core/src/tests/TaskRunner.spec.ts | 2 +- packages/testing/package.json | 2 +- packages/testing/src/libs/TestKernel.ts | 3 +-- packages/testing/src/libs/TestRuntime.ts | 21 +++++++++++++++++-- packages/testing/src/tests/TestKernel.spec.ts | 6 ++++++ .../testing/src/tests/TestRuntime.spec.ts | 8 +++++++ pnpm-lock.yaml | 6 +++--- 11 files changed, 44 insertions(+), 14 deletions(-) diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json index 16438ea69..a632569e0 100644 --- a/docs/problem-code-registry.json +++ b/docs/problem-code-registry.json @@ -1,6 +1,6 @@ { "version": "croco.problem-code-registry.v1", - "problemCount": 571, + "problemCount": 573, "problems": [ { "code": "ACCESS_DENIED", diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md index 70be5d7c5..f700a0d3c 100644 --- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md +++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md @@ -7,7 +7,7 @@ description: Generated Croco Problem code registry with recovery and telemetry m > Generated by `pnpm problem-registry:write`. Do not edit this file by hand. -This cookbook documents 571 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`, and generated client union types are emitted at `packages/problems-core/src/generated/problem-code-registry.ts`. +This cookbook documents 573 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`, and generated client union types are emitted at `packages/problems-core/src/generated/problem-code-registry.ts`. ## Index diff --git a/packages/problems-core/src/generated/problem-code-registry.ts b/packages/problems-core/src/generated/problem-code-registry.ts index 5ec95aa21..58ebf2005 100644 --- a/packages/problems-core/src/generated/problem-code-registry.ts +++ b/packages/problems-core/src/generated/problem-code-registry.ts @@ -3,7 +3,7 @@ import type { ProblemCodeRegistry } from "../libs/ProblemRegistry"; export const CROCO_PROBLEM_CODE_REGISTRY = { version: "croco.problem-code-registry.v1", - problemCount: 571, + problemCount: 573, problems: [ { code: "ACCESS_DENIED", diff --git a/packages/tasks-core/src/libs/TaskRunner.ts b/packages/tasks-core/src/libs/TaskRunner.ts index df2763c2f..dee10ee48 100644 --- a/packages/tasks-core/src/libs/TaskRunner.ts +++ b/packages/tasks-core/src/libs/TaskRunner.ts @@ -14,10 +14,10 @@ type Constructor = new (...args: unknown[]) => T; const MAX_TIMER_DELAY_MS = 2_147_483_647; -export type TaskRunnerRuntime = { +export interface TaskRunnerRuntime { readonly now?: () => number; readonly schedule?: (callback: () => void, delayMs: number) => () => void; -}; +} const noopLogger: ILogger = { debug: () => {}, diff --git a/packages/tasks-core/src/tests/TaskRunner.spec.ts b/packages/tasks-core/src/tests/TaskRunner.spec.ts index 238cdb7e0..040b67b48 100644 --- a/packages/tasks-core/src/tests/TaskRunner.spec.ts +++ b/packages/tasks-core/src/tests/TaskRunner.spec.ts @@ -64,7 +64,7 @@ describe("TaskRunner", () => { reconcileTimedOut: vi.fn().mockResolvedValue({ scanned: 0, timedOut: 0 }), }; - @Component() + @Component({ scope: "singleton" }) class TestTaskHandler { @Task({ name: "test-task" }) async handle(payload: { data: string }): Promise { diff --git a/packages/testing/package.json b/packages/testing/package.json index 0ffcbf557..bda0dd612 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -60,7 +60,6 @@ "@croco/openapi-spec": "workspace:*", "@croco/problems-core": "workspace:*", "@croco/ratelimit-core": "workspace:*", - "@croco/retry-core": "workspace:*", "@croco/storage-core": "workspace:*", "@croco/telemetry-api": "workspace:*", "@croco/transports-http": "workspace:*", @@ -69,6 +68,7 @@ }, "devDependencies": { "@croco/protocols-rest": "workspace:*", + "@croco/retry-core": "workspace:*", "tsup": "8.5.1", "vitest": "4.0.16" } diff --git a/packages/testing/src/libs/TestKernel.ts b/packages/testing/src/libs/TestKernel.ts index f560ff13a..fab1b46af 100644 --- a/packages/testing/src/libs/TestKernel.ts +++ b/packages/testing/src/libs/TestKernel.ts @@ -519,8 +519,7 @@ export async function createTestKernel(options: TestKernelOptions): Promise>> 0) / 0x1_0000_0000; } + + fork(): TestRandomSource { + const fork = new TestRandomSource(this.seed); + fork.state = this.state; + return fork; + } } export class TestIdSource { private sequence = 0; - readonly random: TestRandomSource; + private randomSource: TestRandomSource; constructor(readonly seed: string) { - this.random = new TestRandomSource(seed); + this.randomSource = new TestRandomSource(seed); + } + + get random(): TestRandomSource { + return this.randomSource; + } + + fork(): TestIdSource { + const fork = new TestIdSource(this.seed); + fork.sequence = this.sequence; + fork.randomSource = this.random.fork(); + return fork; } next(prefix = "test"): string { diff --git a/packages/testing/src/tests/TestKernel.spec.ts b/packages/testing/src/tests/TestKernel.spec.ts index e5c403635..aa077efac 100644 --- a/packages/testing/src/tests/TestKernel.spec.ts +++ b/packages/testing/src/tests/TestKernel.spec.ts @@ -746,6 +746,10 @@ describe("TestKernel", () => { it("clones caller-provided controls so concurrent kernels do not share time or ID state", async () => { const sharedClock = fixedClock("2026-01-01T00:00:00.000Z"); const sharedIds = seededIds("concurrent-kernels"); + sharedIds.next("already-used"); + const expectedIds = sharedIds.fork(); + expectedIds.next("scenario"); + expectedIds.next("test"); const [first, second] = await Promise.all( ["first", "second"].map((value) => createTestKernel({ @@ -759,9 +763,11 @@ describe("TestKernel", () => { await first.clock.advanceBy("30s"); const firstExtraId = first.ids.next("extra"); + const expectedExtraId = expectedIds.next("extra"); expect(first.clock.now.toISOString()).toBe("2026-01-01T00:00:30.000Z"); expect(second.clock.now.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + expect(firstExtraId).toBe(expectedExtraId); expect(second.ids.next("extra")).toBe(firstExtraId); await Promise.all([first.dispose(), second.dispose()]); diff --git a/packages/testing/src/tests/TestRuntime.spec.ts b/packages/testing/src/tests/TestRuntime.spec.ts index 732b5a1c9..57694854a 100644 --- a/packages/testing/src/tests/TestRuntime.spec.ts +++ b/packages/testing/src/tests/TestRuntime.spec.ts @@ -108,6 +108,14 @@ describe("TestRuntime", () => { expect(runtimeWithAdvancedIds.ids).toBe(advancedIds); }); + it("forks seeded ID sources without resetting their sequence or entropy", () => { + const ids = seededIds("forked-ids"); + ids.next("already-used"); + const fork = ids.fork(); + + expect(fork.next("next")).toBe(ids.next("next")); + }); + it("reports invalid time controls with a stable Problem code", () => { expect(() => fixedClock("not-a-date")).toThrow(TestRuntimeConfigurationProblem); expect(() => fixedClock("2026-01-01T00:00:00.000Z").schedule(() => undefined, -1)).toThrow( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e922aead6..eee5e5be5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3314,9 +3314,6 @@ importers: '@croco/ratelimit-core': specifier: workspace:* version: link:../ratelimit-core - '@croco/retry-core': - specifier: workspace:* - version: link:../retry-core '@croco/storage-core': specifier: workspace:* version: link:../storage-core @@ -3336,6 +3333,9 @@ importers: '@croco/protocols-rest': specifier: workspace:* version: link:../protocols-rest + '@croco/retry-core': + specifier: workspace:* + version: link:../retry-core tsup: specifier: 8.5.1 version: 8.5.1(patch_hash=ce9dbc714c187cea78868f1e68c1a0e5097ddbceb7e976a564d94f2291b5bcb9)(@swc/core@1.15.43)(jiti@2.6.1)(postcss@8.5.18)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0) From 53421ff7a6aab4542dd0252b57f0453f22ff63d9 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Tue, 4 Aug 2026 15:47:51 +0900 Subject: [PATCH 7/9] fix: enforce deterministic runtime boundaries --- docs/problem-code-registry.json | 44 +++++++++++--- .../en/reference/problem-recovery-cookbook.md | 33 ++++++++--- .../src/generated/problem-code-registry.ts | 44 +++++++++++--- packages/ratelimit-core/src/index.ts | 1 + .../src/libs/InMemoryRateLimitStore.ts | 12 +++- .../libs/problems/RateLimitConfigProblems.ts | 18 ++++++ .../src/tests/InMemoryRateLimitStore.spec.ts | 10 ++++ packages/testing/src/index.ts | 57 ++++++++++--------- packages/testing/src/libs/TestRuntime.ts | 51 ++++++++++++++--- packages/testing/src/tests/TestKernel.spec.ts | 16 ++++-- .../testing/src/tests/TestRuntime.spec.ts | 18 +++++- public-api-surface.snapshot.json | 6 ++ 12 files changed, 246 insertions(+), 64 deletions(-) diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json index a632569e0..24de7603a 100644 --- a/docs/problem-code-registry.json +++ b/docs/problem-code-registry.json @@ -1,6 +1,6 @@ { "version": "croco.problem-code-registry.v1", - "problemCount": 573, + "problemCount": 574, "problems": [ { "code": "ACCESS_DENIED", @@ -11966,7 +11966,7 @@ "sources": [ { "file": "packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts", - "line": 4, + "line": 6, "column": 3, "kind": "problem-class" } @@ -11996,7 +11996,7 @@ "sources": [ { "file": "packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts", - "line": 25, + "line": 43, "column": 5, "kind": "problem-constructor" } @@ -12026,7 +12026,7 @@ "sources": [ { "file": "packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts", - "line": 14, + "line": 16, "column": 3, "kind": "problem-class" } @@ -12092,6 +12092,36 @@ } ] }, + { + "code": "ratelimit/prune-interval", + "category": "ValidationError", + "status": 422, + "title": "Validation Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#ratelimit-prune-interval", + "recovery": { + "cause": "The request or generated contract failed schema or semantic validation.", + "userAction": "Fix the invalid fields and retry with schema-conformant input.", + "operatorAction": "Inspect schema diagnostics, generated contracts, and validation metadata.", + "retryability": "not-retryable", + "redactionPolicy": "public", + "telemetry": { + "eventName": "croco.problem.info", + "severity": "info", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "lifecycle": { + "status": "active" + }, + "sources": [ + { + "file": "packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts", + "line": 27, + "column": 5, + "kind": "problem-constructor" + } + ] + }, { "code": "repository-core/batch-loader-factory-not-registered", "category": "InternalServerError", @@ -14726,7 +14756,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestRuntime.ts", - "line": 44, + "line": 49, "column": 5, "kind": "problem-constructor" } @@ -14876,7 +14906,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestRuntime.ts", - "line": 60, + "line": 65, "column": 5, "kind": "problem-constructor" } @@ -14906,7 +14936,7 @@ "sources": [ { "file": "packages/testing/src/libs/TestRuntime.ts", - "line": 71, + "line": 76, "column": 5, "kind": "problem-constructor" } diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md index f700a0d3c..018adfb52 100644 --- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md +++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md @@ -7,7 +7,7 @@ description: Generated Croco Problem code registry with recovery and telemetry m > Generated by `pnpm problem-registry:write`. Do not edit this file by hand. -This cookbook documents 573 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`, and generated client union types are emitted at `packages/problems-core/src/generated/problem-code-registry.ts`. +This cookbook documents 574 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`, and generated client union types are emitted at `packages/problems-core/src/generated/problem-code-registry.ts`. ## Index @@ -416,6 +416,7 @@ This cookbook documents 573 public Croco Problem codes. The deterministic JSON r | [`RATE_LIMIT_WINDOW_ERROR`](#rate-limit-window-error) | BadRequest | 400 | not-retryable | public | active | 1 | | [`ratelimit-upstash/invalid-policy`](#ratelimit-upstash-invalid-policy) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`ratelimit-upstash/missing-config`](#ratelimit-upstash-missing-config) | InternalServerError | 500 | conditional | operator-only | active | 1 | +| [`ratelimit/prune-interval`](#ratelimit-prune-interval) | ValidationError | 422 | not-retryable | public | active | 1 | | [`repository-core/batch-loader-factory-not-registered`](#repository-core-batch-loader-factory-not-registered) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`repository-core/batch-loader-factory-resolution-failed`](#repository-core-batch-loader-factory-resolution-failed) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`repository-core/batch-loader-scope-collision`](#repository-core-batch-loader-scope-collision) | InternalServerError | 500 | conditional | operator-only | active | 1 | @@ -7771,7 +7772,7 @@ Sources: Sources: -- `packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts:4:3` (problem-class) +- `packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts:6:3` (problem-class) @@ -7789,7 +7790,7 @@ Sources: Sources: -- `packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts:25:5` (problem-constructor) +- `packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts:43:5` (problem-constructor) @@ -7807,7 +7808,7 @@ Sources: Sources: -- `packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts:14:3` (problem-class) +- `packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts:16:3` (problem-class) @@ -7845,6 +7846,24 @@ Sources: - `packages/ratelimit-upstash/src/libs/problems/RateLimitUpstashProblems.ts:21:3` (problem-class) + + +## `ratelimit/prune-interval` + +- Category: `ValidationError` +- HTTP status: `422` Validation Error +- Retryability: `not-retryable` +- Redaction policy: `public` +- Lifecycle: `active` +- Cause: The request or generated contract failed schema or semantic validation. +- User action: Fix the invalid fields and retry with schema-conformant input. +- Operator action: Inspect schema diagnostics, generated contracts, and validation metadata. +- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts:27:5` (problem-constructor) + ## `repository-core/batch-loader-factory-not-registered` @@ -9427,7 +9446,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestRuntime.ts:44:5` (problem-constructor) +- `packages/testing/src/libs/TestRuntime.ts:49:5` (problem-constructor) @@ -9517,7 +9536,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestRuntime.ts:60:5` (problem-constructor) +- `packages/testing/src/libs/TestRuntime.ts:65:5` (problem-constructor) @@ -9535,7 +9554,7 @@ Sources: Sources: -- `packages/testing/src/libs/TestRuntime.ts:71:5` (problem-constructor) +- `packages/testing/src/libs/TestRuntime.ts:76:5` (problem-constructor) diff --git a/packages/problems-core/src/generated/problem-code-registry.ts b/packages/problems-core/src/generated/problem-code-registry.ts index 58ebf2005..bd62084f1 100644 --- a/packages/problems-core/src/generated/problem-code-registry.ts +++ b/packages/problems-core/src/generated/problem-code-registry.ts @@ -3,7 +3,7 @@ import type { ProblemCodeRegistry } from "../libs/ProblemRegistry"; export const CROCO_PROBLEM_CODE_REGISTRY = { version: "croco.problem-code-registry.v1", - problemCount: 573, + problemCount: 574, problems: [ { code: "ACCESS_DENIED", @@ -12449,7 +12449,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts", - line: 4, + line: 6, column: 3, kind: "problem-class", }, @@ -12481,7 +12481,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts", - line: 25, + line: 43, column: 5, kind: "problem-constructor", }, @@ -12512,7 +12512,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts", - line: 14, + line: 16, column: 3, kind: "problem-class", }, @@ -12582,6 +12582,36 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { }, ], }, + { + code: "ratelimit/prune-interval", + category: "ValidationError", + status: 422, + title: "Validation Error", + cookbookPath: "/reference/problem-recovery-cookbook/#ratelimit-prune-interval", + recovery: { + cause: "The request or generated contract failed schema or semantic validation.", + userAction: "Fix the invalid fields and retry with schema-conformant input.", + operatorAction: "Inspect schema diagnostics, generated contracts, and validation metadata.", + retryability: "not-retryable", + redactionPolicy: "public", + telemetry: { + eventName: "croco.problem.info", + severity: "info", + attributes: ["problem.code", "problem.category", "problem.status"], + }, + }, + lifecycle: { + status: "active", + }, + sources: [ + { + file: "packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts", + line: 27, + column: 5, + kind: "problem-constructor", + }, + ], + }, { code: "repository-core/batch-loader-factory-not-registered", category: "InternalServerError", @@ -15349,7 +15379,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestRuntime.ts", - line: 44, + line: 49, column: 5, kind: "problem-constructor", }, @@ -15508,7 +15538,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestRuntime.ts", - line: 60, + line: 65, column: 5, kind: "problem-constructor", }, @@ -15540,7 +15570,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { sources: [ { file: "packages/testing/src/libs/TestRuntime.ts", - line: 71, + line: 76, column: 5, kind: "problem-constructor", }, diff --git a/packages/ratelimit-core/src/index.ts b/packages/ratelimit-core/src/index.ts index 0c739caa6..7f7eb1db1 100644 --- a/packages/ratelimit-core/src/index.ts +++ b/packages/ratelimit-core/src/index.ts @@ -47,6 +47,7 @@ export { */ export { RateLimitKeyBuilderProblem, + RateLimitPruneIntervalProblem, RateLimitRefundUnsupportedProblem, RateLimitWindowProblem, } from "./libs/problems/RateLimitConfigProblems"; diff --git a/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts b/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts index 63b079ef3..cdba632c2 100644 --- a/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts +++ b/packages/ratelimit-core/src/libs/InMemoryRateLimitStore.ts @@ -8,7 +8,10 @@ import type { SlidingWindowPolicy, TokenBucketPolicy, } from "./types"; -import { RateLimitRefundUnsupportedProblem } from "./problems/RateLimitConfigProblems"; +import { + RateLimitPruneIntervalProblem, + RateLimitRefundUnsupportedProblem, +} from "./problems/RateLimitConfigProblems"; export type InMemoryRateLimitStoreOptions = { readonly now?: () => number; @@ -22,6 +25,7 @@ export interface RateLimitPruneScheduler { } const DEFAULT_PRUNE_INTERVAL_MS = 60000; +const MAX_NATIVE_TIMER_DELAY_MS = 2_147_483_647; const DEFAULT_PRUNE_SCHEDULER: RateLimitPruneScheduler = { schedule(callback, intervalMs) { @@ -50,6 +54,12 @@ function schedulePruning( ): (() => void) | undefined { const pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS; if (pruneIntervalMs <= 0) return undefined; + if ( + options.scheduler === undefined && + (!Number.isFinite(pruneIntervalMs) || pruneIntervalMs > MAX_NATIVE_TIMER_DELAY_MS) + ) { + throw new RateLimitPruneIntervalProblem(pruneIntervalMs); + } const scheduler = options.scheduler ?? DEFAULT_PRUNE_SCHEDULER; let cancelScheduledWork: (() => void) | undefined; let closed = false; diff --git a/packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts b/packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts index fc565dc35..34459a6d7 100644 --- a/packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts +++ b/packages/ratelimit-core/src/libs/problems/RateLimitConfigProblems.ts @@ -1,5 +1,7 @@ import { Problem, ProblemCategory } from "@croco/problems-core"; +const MAX_NATIVE_TIMER_DELAY_MS = 2_147_483_647; + export class RateLimitKeyBuilderProblem extends Problem { readonly code = "RATE_LIMIT_KEY_BUILDER_ERROR"; readonly category = ProblemCategory.InternalServerError; @@ -20,6 +22,22 @@ export class RateLimitWindowProblem extends Problem { } } +export class RateLimitPruneIntervalProblem extends Problem { + constructor(value: number) { + super( + "ratelimit/prune-interval", + ProblemCategory.ValidationError, + `Rate limit prune interval must be a finite delay no greater than ${MAX_NATIVE_TIMER_DELAY_MS}ms; received '${String(value)}'.`, + { + extensions: { + maxDelayMs: MAX_NATIVE_TIMER_DELAY_MS, + value: String(value), + }, + }, + ); + } +} + export class RateLimitRefundUnsupportedProblem extends Problem { constructor() { super( diff --git a/packages/ratelimit-core/src/tests/InMemoryRateLimitStore.spec.ts b/packages/ratelimit-core/src/tests/InMemoryRateLimitStore.spec.ts index 725972a6f..4d10ac954 100644 --- a/packages/ratelimit-core/src/tests/InMemoryRateLimitStore.spec.ts +++ b/packages/ratelimit-core/src/tests/InMemoryRateLimitStore.spec.ts @@ -5,6 +5,7 @@ import { SlidingWindowInMemoryStore, TokenBucketInMemoryStore, } from "../libs/InMemoryRateLimitStore"; +import { RateLimitPruneIntervalProblem } from "../libs/problems/RateLimitConfigProblems"; import type { FixedWindowPolicy, SlidingWindowPolicy, TokenBucketPolicy } from "../libs/types"; describe("InMemoryRateLimitStore", () => { @@ -24,6 +25,15 @@ describe("InMemoryRateLimitStore", () => { store.close(); }); + it.each([Number.NaN, Number.POSITIVE_INFINITY, 2_147_483_648])( + "rejects unsupported native prune intervals (%s) with a stable Problem", + (pruneIntervalMs) => { + expect(() => new InMemoryRateLimitStore({ pruneIntervalMs })).toThrow( + RateLimitPruneIntervalProblem, + ); + }, + ); + it("should allow requests within limit", async () => { const result1 = await store.check("user:1", policy); expect(result1.success).toBe(true); diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index 779c9d36c..5d906ba0a 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -88,26 +88,6 @@ export { TestKernelResourceNotFoundProblem, TestKernelResourceRegistrationProblem, TestKernelValidationProblem, - type StartedTestResource, - type TestKernelBootFidelity, - type TestKernelBootstrapContext, - type TestKernelBootstrapResult, - type TestKernelEvidence, - type TestKernelFidelity, - type TestKernelLeak, - type TestKernelOptions, - type TestKernelResourceEvidence, - type TestKernelResourceObligation, - type TestKernelRuntime, - type TestKernelTrackedWork, - type TestKernelValidationFidelity, - type TestResource, - type TestResourceDiagnostic, - type TestResourceDiagnosticStage, - type TestResourceFidelity, - type TestResourceIsolation, - type TestResourceMode, - type TestResourceStartContext, } from "./libs/TestKernel"; export { fixedClock, @@ -121,12 +101,6 @@ export { TestRandomSource, TestRuntime, TestRuntimeConfigurationProblem, - type TestDuration, - type TestEnvironmentOverrides, - type TestReplayMetadata, - type TestRetryDependencies, - type TestRuntimeOptions, - type TestScheduledWork, } from "./libs/TestRuntime"; export { installTestingTelemetryCapture, @@ -309,3 +283,34 @@ export { type OperationalFailureDrillScenario, type OperationalFailureDrillScenarioId, } from "./libs/operational-failure-drill"; + +export type { + StartedTestResource, + TestKernelBootFidelity, + TestKernelBootstrapContext, + TestKernelBootstrapResult, + TestKernelEvidence, + TestKernelFidelity, + TestKernelLeak, + TestKernelOptions, + TestKernelResourceEvidence, + TestKernelResourceObligation, + TestKernelRuntime, + TestKernelTrackedWork, + TestKernelValidationFidelity, + TestResource, + TestResourceDiagnostic, + TestResourceDiagnosticStage, + TestResourceFidelity, + TestResourceIsolation, + TestResourceMode, + TestResourceStartContext, +} from "./libs/TestKernel"; +export type { + TestDuration, + TestEnvironmentOverrides, + TestReplayMetadata, + TestRetryDependencies, + TestRuntimeOptions, + TestScheduledWork, +} from "./libs/TestRuntime"; diff --git a/packages/testing/src/libs/TestRuntime.ts b/packages/testing/src/libs/TestRuntime.ts index ddd4f3a91..21cb98fbc 100644 --- a/packages/testing/src/libs/TestRuntime.ts +++ b/packages/testing/src/libs/TestRuntime.ts @@ -38,6 +38,11 @@ type ScheduledEntry = { }; const MAX_DRAIN_CALLBACKS = 10_000; +const MAX_DATE_TIME_MS = 8_640_000_000_000_000; + +type DrainBudget = { + callbackCount: number; +}; export class TestKernelOutboundCallProblem extends Problem { constructor(host: string) { @@ -90,7 +95,7 @@ export class TestClock { constructor(initial: Date | string = "2026-01-01T00:00:00.000Z") { const time = new Date(initial).getTime(); - if (Number.isNaN(time)) { + if (!isValidDateTime(time)) { throw new TestRuntimeConfigurationProblem("clock", String(initial), "a valid date"); } this.currentTimeMs = time; @@ -111,11 +116,12 @@ export class TestClock { delay: TestDuration, source = "scheduled-work", ): () => void { + const dueAtMs = nextDateTime(this.currentTimeMs, parseDuration(delay)); const sequence = ++this.scheduledSequence; const id = `scheduled-${sequence}`; this.scheduled.set(id, { callback, - dueAtMs: this.currentTimeMs + parseDuration(delay), + dueAtMs, id, sequence, source, @@ -130,27 +136,30 @@ export class TestClock { } async advanceBy(duration: TestDuration): Promise { - const targetTime = this.currentTimeMs + parseDuration(duration); + const targetTime = nextDateTime(this.currentTimeMs, parseDuration(duration)); await this.drainUntil(targetTime); this.currentTimeMs = targetTime; } async drain(): Promise { + const budget: DrainBudget = { callbackCount: 0 }; while (this.scheduled.size > 0) { const next = this.nextScheduled(); if (!next) return; - await this.drainUntil(next.dueAtMs); + await this.drainUntil(next.dueAtMs, budget); } } - private async drainUntil(targetTime: number): Promise { - let callbackCount = 0; + private async drainUntil( + targetTime: number, + budget: DrainBudget = { callbackCount: 0 }, + ): Promise { while (true) { const next = this.nextScheduled(); if (!next || next.dueAtMs > targetTime) return; - callbackCount += 1; - if (callbackCount > MAX_DRAIN_CALLBACKS) { + budget.callbackCount += 1; + if (budget.callbackCount > MAX_DRAIN_CALLBACKS) { throw new TestRuntimeDrainProblem(MAX_DRAIN_CALLBACKS); } @@ -318,7 +327,31 @@ function parseDuration(value: TestDuration): number { const amount = Number(match[1]); const unit = match[2]; const multiplier = unit === "m" ? 60_000 : unit === "s" ? 1_000 : 1; - return amount * multiplier; + const duration = amount * multiplier; + if (!Number.isSafeInteger(duration)) { + throw new TestRuntimeConfigurationProblem( + "duration", + String(value), + "a non-negative safe integer duration", + ); + } + return duration; +} + +function nextDateTime(currentTimeMs: number, duration: number): number { + const nextTimeMs = currentTimeMs + duration; + if (!isValidDateTime(nextTimeMs)) { + throw new TestRuntimeConfigurationProblem( + "duration", + String(duration), + "a duration that keeps virtual time within the valid Date range", + ); + } + return nextTimeMs; +} + +function isValidDateTime(value: number): boolean { + return Number.isSafeInteger(value) && Math.abs(value) <= MAX_DATE_TIME_MS; } function hashSeed(seed: string): number { diff --git a/packages/testing/src/tests/TestKernel.spec.ts b/packages/testing/src/tests/TestKernel.spec.ts index aa077efac..4db62341c 100644 --- a/packages/testing/src/tests/TestKernel.spec.ts +++ b/packages/testing/src/tests/TestKernel.spec.ts @@ -13,7 +13,6 @@ import { createTestingTransactionContext, fixedClock, seededIds, - type TestKernel, TestKernelDisposalProblem, TestKernelDisposedProblem, TestKernelLeakProblem, @@ -23,6 +22,7 @@ import { TestKernelValidationProblem, type TestResource, } from "../index"; +import type { TestKernel } from "../index"; class KernelValueService { constructor(readonly value: string) {} @@ -713,11 +713,13 @@ describe("TestKernel", () => { }); it("injects deterministic runtime controls and reports replay metadata", async () => { - let bootstrapReplay!: { - readonly scenarioId: string; - readonly seed: string; - readonly virtualTime: string; - }; + let bootstrapReplay: + | { + readonly scenarioId: string; + readonly seed: string; + readonly virtualTime: string; + } + | undefined; const kernel = await createTestKernel({ bootstrap: (context) => { bootstrapReplay = context.replay; @@ -732,6 +734,8 @@ describe("TestKernel", () => { scenarioId: "retry-timeout", }); + expect(kernel.replay).toBeDefined(); + expect(bootstrapReplay).toBeDefined(); expect(bootstrapReplay).toEqual({ scenarioId: "retry-timeout", seed: "retry-scenario", diff --git a/packages/testing/src/tests/TestRuntime.spec.ts b/packages/testing/src/tests/TestRuntime.spec.ts index 57694854a..b679ed4c3 100644 --- a/packages/testing/src/tests/TestRuntime.spec.ts +++ b/packages/testing/src/tests/TestRuntime.spec.ts @@ -78,6 +78,16 @@ describe("TestRuntime", () => { await expect(clock.drain()).rejects.toThrow(TestRuntimeDrainProblem); }); + it("reports non-terminating positive-delay rescheduling with a stable Problem", async () => { + const clock = fixedClock("2026-01-01T00:00:00.000Z"); + const reschedule = () => { + clock.schedule(reschedule, 1, "positive-delay-loop"); + }; + clock.schedule(reschedule, 1, "positive-delay-loop"); + + await expect(clock.drain()).rejects.toThrow(TestRuntimeDrainProblem); + }); + it("replays seeded ids and random values without touching process state", () => { const first = seededIds("invitation-retry"); const second = seededIds("invitation-retry"); @@ -116,11 +126,17 @@ describe("TestRuntime", () => { expect(fork.next("next")).toBe(ids.next("next")); }); - it("reports invalid time controls with a stable Problem code", () => { + it("reports invalid time controls with a stable Problem code", async () => { expect(() => fixedClock("not-a-date")).toThrow(TestRuntimeConfigurationProblem); expect(() => fixedClock("2026-01-01T00:00:00.000Z").schedule(() => undefined, -1)).toThrow( TestRuntimeConfigurationProblem, ); + expect(() => fixedClock(new Date(8_640_000_000_000_000)).schedule(() => undefined, 1)).toThrow( + TestRuntimeConfigurationProblem, + ); + await expect( + fixedClock("2026-01-01T00:00:00.000Z").advanceBy(Number.MAX_SAFE_INTEGER), + ).rejects.toThrow(TestRuntimeConfigurationProblem); }); it("rejects outbound calls by default with a provider-facing diagnostic", async () => { diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index c3db69a62..0a7145b38 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -22208,6 +22208,12 @@ "source": "./libs/problems/RateLimitConfigProblems", "declarationKind": "class" }, + { + "name": "RateLimitPruneIntervalProblem", + "exportKind": "named", + "source": "./libs/problems/RateLimitConfigProblems", + "declarationKind": "class" + }, { "name": "RateLimitRefundUnsupportedProblem", "exportKind": "named", From 12df1fb943a820bb76a736f7f3fae86396dccc56 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Tue, 4 Aug 2026 22:40:43 +0900 Subject: [PATCH 8/9] fix: keep deterministic runtime boundaries isolated --- .../src/tests/InMemoryRateLimitStore.spec.ts | 2 ++ packages/tasks-core/src/index.ts | 6 +----- packages/testing/src/libs/TestKernel.ts | 4 +++- packages/testing/src/tests/TestKernel.spec.ts | 5 +++-- packages/testing/src/tests/TestRuntime.spec.ts | 7 ++++++- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/ratelimit-core/src/tests/InMemoryRateLimitStore.spec.ts b/packages/ratelimit-core/src/tests/InMemoryRateLimitStore.spec.ts index 4d10ac954..8ec766733 100644 --- a/packages/ratelimit-core/src/tests/InMemoryRateLimitStore.spec.ts +++ b/packages/ratelimit-core/src/tests/InMemoryRateLimitStore.spec.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Container } from "@croco/framework-context"; import { FixedWindowInMemoryStore, InMemoryRateLimitStore, @@ -18,6 +19,7 @@ describe("InMemoryRateLimitStore", () => { }; beforeEach(() => { + Container.reset(); store = new InMemoryRateLimitStore({ pruneIntervalMs: 0 }); }); diff --git a/packages/tasks-core/src/index.ts b/packages/tasks-core/src/index.ts index f8f2a4c41..0c0700e8f 100644 --- a/packages/tasks-core/src/index.ts +++ b/packages/tasks-core/src/index.ts @@ -13,11 +13,6 @@ export { TaskRunnerDIFailureProblem, } from "./libs/problems/TasksProblems"; -/** - * 레지스트리에 등록된 태스크 엔트리 타입입니다. - */ -export type { RegisteredTask } from "./libs/TaskRegistry"; - /** * 태스크 메타데이터를 수집하고 조회하는 전역 레지스트리입니다. */ @@ -39,4 +34,5 @@ export type { TaskReference, } from "./libs/types"; +export type { RegisteredTask } from "./libs/TaskRegistry"; export type { TaskRunnerRuntime } from "./libs/TaskRunner"; diff --git a/packages/testing/src/libs/TestKernel.ts b/packages/testing/src/libs/TestKernel.ts index fab1b46af..98a4e73ff 100644 --- a/packages/testing/src/libs/TestKernel.ts +++ b/packages/testing/src/libs/TestKernel.ts @@ -543,7 +543,9 @@ export async function createTestKernel(options: TestKernelOptions): Promise { } | undefined; const kernel = await createTestKernel({ - bootstrap: (context) => { + bootstrap: async (context) => { + await context.clock.advanceBy("1m"); bootstrapReplay = context.replay; expect(context.environment.get("CROCO_TEST_KERNEL_SCOPE")).toBe("first"); expect(context.ids.next("bootstrap")).toContain("retry-scenario"); @@ -739,7 +740,7 @@ describe("TestKernel", () => { expect(bootstrapReplay).toEqual({ scenarioId: "retry-timeout", seed: "retry-scenario", - virtualTime: "2026-02-03T04:05:06.000Z", + virtualTime: "2026-02-03T04:06:06.000Z", }); expect(kernel.replay).toEqual(bootstrapReplay); expect(process.env["CROCO_TEST_KERNEL_SCOPE"]).not.toBe("first"); diff --git a/packages/testing/src/tests/TestRuntime.spec.ts b/packages/testing/src/tests/TestRuntime.spec.ts index b679ed4c3..35738f685 100644 --- a/packages/testing/src/tests/TestRuntime.spec.ts +++ b/packages/testing/src/tests/TestRuntime.spec.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { createSlidingWindowPolicy, SlidingWindowInMemoryStore } from "@croco/ratelimit-core"; import { ExponentialBackoff } from "@croco/retry-core"; import { fixedClock, + resetCrocoTestingContext, seededIds, TestKernelOutboundCallProblem, TestRuntime, @@ -11,6 +12,10 @@ import { } from "../index"; describe("TestRuntime", () => { + beforeEach(() => { + resetCrocoTestingContext(); + }); + it("advances virtual time and drains only Croco-owned scheduled work in order", async () => { const clock = fixedClock("2026-01-01T00:00:00.000Z"); const events: string[] = []; From 97ee02c84328d9e728e8eec29f671da934ab28b9 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Wed, 5 Aug 2026 14:21:14 +0900 Subject: [PATCH 9/9] fix: keep deterministic runtime problem registry current --- docs/problem-code-registry.json | 2 +- .../src/content/docs/en/reference/problem-recovery-cookbook.md | 2 +- packages/problems-core/src/generated/problem-code-registry.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json index 24de7603a..9dbdfd553 100644 --- a/docs/problem-code-registry.json +++ b/docs/problem-code-registry.json @@ -1,6 +1,6 @@ { "version": "croco.problem-code-registry.v1", - "problemCount": 574, + "problemCount": 576, "problems": [ { "code": "ACCESS_DENIED", diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md index 018adfb52..b7fd5c74a 100644 --- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md +++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md @@ -7,7 +7,7 @@ description: Generated Croco Problem code registry with recovery and telemetry m > Generated by `pnpm problem-registry:write`. Do not edit this file by hand. -This cookbook documents 574 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`, and generated client union types are emitted at `packages/problems-core/src/generated/problem-code-registry.ts`. +This cookbook documents 576 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`, and generated client union types are emitted at `packages/problems-core/src/generated/problem-code-registry.ts`. ## Index diff --git a/packages/problems-core/src/generated/problem-code-registry.ts b/packages/problems-core/src/generated/problem-code-registry.ts index bd62084f1..da295df32 100644 --- a/packages/problems-core/src/generated/problem-code-registry.ts +++ b/packages/problems-core/src/generated/problem-code-registry.ts @@ -3,7 +3,7 @@ import type { ProblemCodeRegistry } from "../libs/ProblemRegistry"; export const CROCO_PROBLEM_CODE_REGISTRY = { version: "croco.problem-code-registry.v1", - problemCount: 574, + problemCount: 576, problems: [ { code: "ACCESS_DENIED",