test: harden HTTP scenario execution evidence
This commit is contained in:
@@ -3,7 +3,7 @@ import type { SpawnSyncOptionsWithStringEncoding } from "node:child_process";
|
|||||||
export const PNPM_SCRIPT_TIMEOUT_MS = 60_000;
|
export const PNPM_SCRIPT_TIMEOUT_MS = 60_000;
|
||||||
export const PNPM_SCRIPT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
|
export const PNPM_SCRIPT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
type BoundedPnpmScriptInvocation = Readonly<{
|
type BoundedChildInvocation = Readonly<{
|
||||||
command: string;
|
command: string;
|
||||||
arguments: readonly string[];
|
arguments: readonly string[];
|
||||||
options: SpawnSyncOptionsWithStringEncoding;
|
options: SpawnSyncOptionsWithStringEncoding;
|
||||||
@@ -20,11 +20,25 @@ export function createBoundedPnpmScriptInvocation(input: Readonly<{
|
|||||||
pnpmCli: string;
|
pnpmCli: string;
|
||||||
script: string;
|
script: string;
|
||||||
environment: NodeJS.ProcessEnv;
|
environment: NodeJS.ProcessEnv;
|
||||||
}>): BoundedPnpmScriptInvocation {
|
}>): BoundedChildInvocation {
|
||||||
return Object.freeze({
|
return createBoundedChildInvocation({
|
||||||
command: input.nodePath,
|
command: input.nodePath,
|
||||||
arguments: Object.freeze([input.pnpmCli, "run", input.script]),
|
arguments: [input.pnpmCli, "run", input.script],
|
||||||
|
environment: input.environment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBoundedChildInvocation(input: Readonly<{
|
||||||
|
command: string;
|
||||||
|
arguments: readonly string[];
|
||||||
|
environment: NodeJS.ProcessEnv;
|
||||||
|
cwd?: string;
|
||||||
|
}>): BoundedChildInvocation {
|
||||||
|
return Object.freeze({
|
||||||
|
command: input.command,
|
||||||
|
arguments: Object.freeze([...input.arguments]),
|
||||||
options: Object.freeze({
|
options: Object.freeze({
|
||||||
|
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
env: input.environment,
|
env: input.environment,
|
||||||
killSignal: "SIGTERM",
|
killSignal: "SIGTERM",
|
||||||
|
|||||||
@@ -80,6 +80,26 @@ export const httpScenarioAssertionGroupsSchema = z
|
|||||||
if (groups.retry.count !== groups.retry.reasons.length) {
|
if (groups.retry.count !== groups.retry.reasons.length) {
|
||||||
fail(["retry", "count"], "must equal retry reasons length");
|
fail(["retry", "count"], "must equal retry reasons length");
|
||||||
}
|
}
|
||||||
|
if (groups.retry.count !== groups.fetch.count - 1) {
|
||||||
|
fail(["retry", "count"], "must equal the non-final fetch attempt count");
|
||||||
|
}
|
||||||
|
for (const [index, reason] of groups.retry.reasons.entries()) {
|
||||||
|
const status = groups.status.attempts[index];
|
||||||
|
const expectedReason =
|
||||||
|
status === "NETWORK_REJECTION"
|
||||||
|
? "NETWORK_FAILURE"
|
||||||
|
: status === 429
|
||||||
|
? "HTTP_429"
|
||||||
|
: status === 503
|
||||||
|
? "HTTP_503"
|
||||||
|
: null;
|
||||||
|
if (reason !== expectedReason) {
|
||||||
|
fail(
|
||||||
|
["retry", "reasons", index],
|
||||||
|
"must match the corresponding non-final attempt status",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (groups.fetch.count !== groups.status.attempts.length) {
|
if (groups.fetch.count !== groups.status.attempts.length) {
|
||||||
fail(["fetch", "count"], "must equal status attempts length");
|
fail(["fetch", "count"], "must equal status attempts length");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export type ContractHttpExecutorDependencies = Readonly<{
|
|||||||
}>,
|
}>,
|
||||||
): Promise<CredentialPatchOutcome> | CredentialPatchOutcome;
|
): Promise<CredentialPatchOutcome> | CredentialPatchOutcome;
|
||||||
fetcher?: typeof fetch;
|
fetcher?: typeof fetch;
|
||||||
|
/** Adapter seam for the common bounded response reader. */
|
||||||
|
readBoundedResponseBytes?: typeof readBoundedBytes;
|
||||||
monotonicNow?: () => number;
|
monotonicNow?: () => number;
|
||||||
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
||||||
random?: () => number;
|
random?: () => number;
|
||||||
@@ -276,6 +278,8 @@ export function createContractHttpExecutor(
|
|||||||
dependencies: ContractHttpExecutorDependencies,
|
dependencies: ContractHttpExecutorDependencies,
|
||||||
): ContractHttpExecutor {
|
): ContractHttpExecutor {
|
||||||
const fetcher = dependencies.fetcher ?? fetch;
|
const fetcher = dependencies.fetcher ?? fetch;
|
||||||
|
const readResponseBytes =
|
||||||
|
dependencies.readBoundedResponseBytes ?? readBoundedBytes;
|
||||||
const now = dependencies.monotonicNow ?? (() => performance.now());
|
const now = dependencies.monotonicNow ?? (() => performance.now());
|
||||||
const random = dependencies.random ?? Math.random;
|
const random = dependencies.random ?? Math.random;
|
||||||
const sleep =
|
const sleep =
|
||||||
@@ -647,6 +651,7 @@ export function createContractHttpExecutor(
|
|||||||
response,
|
response,
|
||||||
context,
|
context,
|
||||||
attemptState,
|
attemptState,
|
||||||
|
readResponseBytes,
|
||||||
);
|
);
|
||||||
attemptState = "SETTLED";
|
attemptState = "SETTLED";
|
||||||
if (
|
if (
|
||||||
@@ -722,6 +727,7 @@ async function admitResponse<Input, WireOutput, Problem>(
|
|||||||
response: Response,
|
response: Response,
|
||||||
context: HttpExecutionContext,
|
context: HttpExecutionContext,
|
||||||
attemptState: PhysicalAttemptState,
|
attemptState: PhysicalAttemptState,
|
||||||
|
readResponseBytes: typeof readBoundedBytes,
|
||||||
): Promise<AdmissionOutcome<WireOutput, Problem>> {
|
): Promise<AdmissionOutcome<WireOutput, Problem>> {
|
||||||
const contract = operation.contract;
|
const contract = operation.contract;
|
||||||
const policy = operation.frontend;
|
const policy = operation.frontend;
|
||||||
@@ -777,7 +783,14 @@ async function admitResponse<Input, WireOutput, Problem>(
|
|||||||
retryAfterMs,
|
retryAfterMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return admitProblem(operation, response, status, metadata, attemptState);
|
return admitProblem(
|
||||||
|
operation,
|
||||||
|
response,
|
||||||
|
status,
|
||||||
|
metadata,
|
||||||
|
attemptState,
|
||||||
|
readResponseBytes,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success status: body policy first.
|
// Success status: body policy first.
|
||||||
@@ -821,7 +834,7 @@ async function admitResponse<Input, WireOutput, Problem>(
|
|||||||
|
|
||||||
const emptyAllowed = contract.emptyBodyStatuses.includes(status);
|
const emptyAllowed = contract.emptyBodyStatuses.includes(status);
|
||||||
const mediaOk = isJsonMediaType(response.headers.get("content-type"));
|
const mediaOk = isJsonMediaType(response.headers.get("content-type"));
|
||||||
const bytes = await readBoundedBytes(response, policy.responseByteLimit);
|
const bytes = await readResponseBytes(response, policy.responseByteLimit);
|
||||||
if (!bytes.ok) {
|
if (!bytes.ok) {
|
||||||
return settled(
|
return settled(
|
||||||
bytes.code === "RESPONSE_TOO_LARGE"
|
bytes.code === "RESPONSE_TOO_LARGE"
|
||||||
@@ -958,12 +971,13 @@ async function admitProblem<Input, WireOutput, Problem>(
|
|||||||
status: number,
|
status: number,
|
||||||
metadata: SafeResponseMetadata,
|
metadata: SafeResponseMetadata,
|
||||||
attemptState: PhysicalAttemptState,
|
attemptState: PhysicalAttemptState,
|
||||||
|
readResponseBytes: typeof readBoundedBytes,
|
||||||
): Promise<AdmissionOutcome<WireOutput, Problem>> {
|
): Promise<AdmissionOutcome<WireOutput, Problem>> {
|
||||||
const contract = operation.contract;
|
const contract = operation.contract;
|
||||||
const isCommand = contract.commandEffect !== null;
|
const isCommand = contract.commandEffect !== null;
|
||||||
const retryable = RETRYABLE_STATUSES.has(status);
|
const retryable = RETRYABLE_STATUSES.has(status);
|
||||||
|
|
||||||
const bytes = await readBoundedBytes(
|
const bytes = await readResponseBytes(
|
||||||
response,
|
response,
|
||||||
HTTP_EXECUTION_CEILINGS.problemResponseBytes,
|
HTTP_EXECUTION_CEILINGS.problemResponseBytes,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"status": { "attempts": [200], "final": 200 },
|
"status": { "attempts": [200], "final": 200 },
|
||||||
"outcome": { "kind": "SUCCESS", "detail": null },
|
"outcome": { "kind": "SUCCESS", "detail": null },
|
||||||
"effect": { "outcome": "NOT_APPLICABLE", "observer": "NOT_APPLICABLE" },
|
"effect": { "outcome": "NOT_APPLICABLE", "observer": "NOT_APPLICABLE" },
|
||||||
"retry": { "count": 0, "reasons": [] },
|
"retry": { "count": 1, "reasons": ["HTTP_503"] },
|
||||||
"fetch": { "count": 1, "observerAttempts": 1, "agrees": false },
|
"fetch": { "count": 1, "observerAttempts": 1, "agrees": false },
|
||||||
"media": { "attempts": ["application/json"], "final": "application/json" },
|
"media": { "attempts": ["application/json"], "final": "application/json" },
|
||||||
"body": { "attempts": [{ "disposition": "FULLY_READ_WITHIN_BOUND", "pulledBytes": 2, "ceiling": 16 }] },
|
"body": { "attempts": [{ "disposition": "FULLY_READ_WITHIN_BOUND", "pulledBytes": 2, "ceiling": 16 }] },
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"status": { "attempts": [200], "final": 200 },
|
"status": { "attempts": [200], "final": 200 },
|
||||||
"outcome": { "kind": "SUCCESS", "detail": null },
|
"outcome": { "kind": "SUCCESS", "detail": null },
|
||||||
"effect": { "outcome": "NOT_APPLICABLE", "observer": "NOT_APPLICABLE" },
|
"effect": { "outcome": "NOT_APPLICABLE", "observer": "NOT_APPLICABLE" },
|
||||||
"retry": { "count": 0, "reasons": [] },
|
"retry": { "count": 1, "reasons": ["HTTP_503"] },
|
||||||
"fetch": { "count": 1, "observerAttempts": 1, "agrees": false },
|
"fetch": { "count": 1, "observerAttempts": 1, "agrees": false },
|
||||||
"media": { "attempts": ["application/json"], "final": "application/json" },
|
"media": { "attempts": ["application/json"], "final": "application/json" },
|
||||||
"body": { "attempts": [{ "disposition": "FULLY_READ_WITHIN_BOUND", "pulledBytes": 2, "ceiling": 16 }] },
|
"body": { "attempts": [{ "disposition": "FULLY_READ_WITHIN_BOUND", "pulledBytes": 2, "ceiling": 16 }] },
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { readBoundedBytes } from "../../src/adapters/http/bounded-body-reader.ts";
|
||||||
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
|
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
|
||||||
import {
|
import {
|
||||||
HTTP_EXECUTION_CEILINGS,
|
|
||||||
type InstalledHttpContract,
|
type InstalledHttpContract,
|
||||||
} from "../../src/contracts/external-contract-runtime.ts";
|
} from "../../src/contracts/external-contract-runtime.ts";
|
||||||
import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts";
|
import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts";
|
||||||
@@ -42,6 +42,7 @@ type AttemptTrace = {
|
|||||||
status: AttemptStatus;
|
status: AttemptStatus;
|
||||||
media: string | null;
|
media: string | null;
|
||||||
pulledBytes: number;
|
pulledBytes: number;
|
||||||
|
appliedCeiling: number | null;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
cancelled: boolean;
|
cancelled: boolean;
|
||||||
};
|
};
|
||||||
@@ -73,6 +74,7 @@ function tracingFetcher(attempts: AttemptTrace[]): typeof fetch {
|
|||||||
status: "PENDING_ABORT",
|
status: "PENDING_ABORT",
|
||||||
media: null,
|
media: null,
|
||||||
pulledBytes: 0,
|
pulledBytes: 0,
|
||||||
|
appliedCeiling: null,
|
||||||
completed: false,
|
completed: false,
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
};
|
};
|
||||||
@@ -109,6 +111,9 @@ function tracingFetcher(attempts: AttemptTrace[]): typeof fetch {
|
|||||||
},
|
},
|
||||||
cancel(reason) {
|
cancel(reason) {
|
||||||
trace.cancelled = true;
|
trace.cancelled = true;
|
||||||
|
// MSW-transferred oversized bodies leave the original cancel promise
|
||||||
|
// permanently pending. Record and request cancellation without making
|
||||||
|
// the proxy response less responsive than the real executor contract.
|
||||||
void reader.cancel(reason).catch(() => {});
|
void reader.cancel(reason).catch(() => {});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -161,19 +166,6 @@ function bodyDisposition(
|
|||||||
throw new Error(`Response body was neither consumed nor cancelled: ${trace.status}`);
|
throw new Error(`Response body was neither consumed nor cancelled: ${trace.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appliedBodyCeiling(
|
|
||||||
trace: AttemptTrace,
|
|
||||||
operation: InstalledHttpContract<unknown, unknown, unknown>,
|
|
||||||
): number {
|
|
||||||
if (typeof trace.status !== "number") return 0;
|
|
||||||
if (operation.contract.acceptedStatuses.includes(trace.status)) {
|
|
||||||
return operation.frontend.responseByteLimit;
|
|
||||||
}
|
|
||||||
return [404, 409, 422, 503].includes(trace.status)
|
|
||||||
? HTTP_EXECUTION_CEILINGS.problemResponseBytes
|
|
||||||
: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForHandler(
|
async function waitForHandler(
|
||||||
ready: Promise<void>,
|
ready: Promise<void>,
|
||||||
executionId: string,
|
executionId: string,
|
||||||
@@ -240,6 +232,15 @@ async function executeScenario(
|
|||||||
}),
|
}),
|
||||||
fetcher,
|
fetcher,
|
||||||
random: () => 0,
|
random: () => 0,
|
||||||
|
readBoundedResponseBytes: async (response, maximumBytes) => {
|
||||||
|
const trace = physicalAttempts.at(-1);
|
||||||
|
if (!trace) throw new Error("Body reader ran before a physical attempt");
|
||||||
|
if (trace.appliedCeiling !== null) {
|
||||||
|
throw new Error("Body reader ran more than once for one attempt");
|
||||||
|
}
|
||||||
|
trace.appliedCeiling = maximumBytes;
|
||||||
|
return readBoundedBytes(response, maximumBytes);
|
||||||
|
},
|
||||||
sleep: async () => {
|
sleep: async () => {
|
||||||
const prior = physicalAttempts.at(-1);
|
const prior = physicalAttempts.at(-1);
|
||||||
if (!prior) throw new Error("Retry sleep occurred before an attempt");
|
if (!prior) throw new Error("Retry sleep occurred before an attempt");
|
||||||
@@ -331,7 +332,7 @@ async function executeScenario(
|
|||||||
Object.freeze({
|
Object.freeze({
|
||||||
disposition: bodyDisposition(attempt, outcome),
|
disposition: bodyDisposition(attempt, outcome),
|
||||||
pulledBytes: attempt.pulledBytes,
|
pulledBytes: attempt.pulledBytes,
|
||||||
ceiling: appliedBodyCeiling(attempt, operation),
|
ceiling: attempt.appliedCeiling ?? 0,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import path from "node:path";
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createBoundedChildInvocation,
|
||||||
createBoundedPnpmScriptInvocation,
|
createBoundedPnpmScriptInvocation,
|
||||||
formatPnpmScriptFailure,
|
formatPnpmScriptFailure,
|
||||||
} from "../../scripts/lib/bounded-pnpm-script.ts";
|
} from "../../scripts/lib/bounded-pnpm-script.ts";
|
||||||
@@ -111,9 +112,9 @@ async function runFixture(
|
|||||||
2,
|
2,
|
||||||
)}\n`,
|
)}\n`,
|
||||||
);
|
);
|
||||||
return spawnSync(
|
const invocation = createBoundedChildInvocation({
|
||||||
process.execPath,
|
command: process.execPath,
|
||||||
[
|
arguments: [
|
||||||
"scripts/check-test-evidence.ts",
|
"scripts/check-test-evidence.ts",
|
||||||
"--scenario-only",
|
"--scenario-only",
|
||||||
"--source-root",
|
"--source-root",
|
||||||
@@ -127,11 +128,41 @@ async function runFixture(
|
|||||||
"--artifact",
|
"--artifact",
|
||||||
artifactPath,
|
artifactPath,
|
||||||
],
|
],
|
||||||
{ encoding: "utf8", cwd: process.cwd() },
|
environment: process.env,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
return spawnSync(
|
||||||
|
invocation.command,
|
||||||
|
[...invocation.arguments],
|
||||||
|
invocation.options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("HTTP scenario evidence receipt schema", () => {
|
describe("HTTP scenario evidence receipt schema", () => {
|
||||||
|
it("bounds checker fixture children with the shared process policy", () => {
|
||||||
|
const environment = { CI: "true" };
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createBoundedChildInvocation({
|
||||||
|
command: "/runtime/node",
|
||||||
|
arguments: ["scripts/check-test-evidence.ts", "--scenario-only"],
|
||||||
|
environment,
|
||||||
|
cwd: "/workspace",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
command: "/runtime/node",
|
||||||
|
arguments: ["scripts/check-test-evidence.ts", "--scenario-only"],
|
||||||
|
options: {
|
||||||
|
cwd: "/workspace",
|
||||||
|
encoding: "utf8",
|
||||||
|
env: environment,
|
||||||
|
killSignal: "SIGTERM",
|
||||||
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("bounds every orchestration child by time and captured output", () => {
|
it("bounds every orchestration child by time and captured output", () => {
|
||||||
const environment = { CI: "true" };
|
const environment = { CI: "true" };
|
||||||
|
|
||||||
@@ -175,6 +206,22 @@ describe("HTTP scenario evidence receipt schema", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves output overflow codes in child diagnostics", () => {
|
||||||
|
const error = Object.assign(new Error("stdout maxBuffer exceeded"), {
|
||||||
|
code: "ENOBUFS",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatPnpmScriptFailure("check:http-scenario-evidence", {
|
||||||
|
status: null,
|
||||||
|
signal: null,
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
"check:http-scenario-evidence failed: exit=null, signal=none, error=ENOBUFS: stdout maxBuffer exceeded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects internally inconsistent assertion groups as schema drift", async () => {
|
it("rejects internally inconsistent assertion groups as schema drift", async () => {
|
||||||
const receipt = JSON.parse(
|
const receipt = JSON.parse(
|
||||||
await readFile(
|
await readFile(
|
||||||
@@ -195,6 +242,18 @@ describe("HTTP scenario evidence receipt schema", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
path: ["rows", 0, "observed", "fetch", "agrees"],
|
path: ["rows", 0, "observed", "fetch", "agrees"],
|
||||||
}),
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
path: ["rows", 0, "expected", "retry", "count"],
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
path: ["rows", 0, "expected", "retry", "reasons", 0],
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
path: ["rows", 0, "observed", "retry", "count"],
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
path: ["rows", 0, "observed", "retry", "reasons", 0],
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,6 +334,13 @@ describe("HTTP scenario evidence receipt schema", () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "a deadline override drift",
|
||||||
|
diagnostic: "[TEST-EVIDENCE-DEADLINE-DRIFT]",
|
||||||
|
mutate(_expectations: HttpScenarioExpectation[], receipt: MutableReceipt) {
|
||||||
|
receipt.rows[0]!.testDeadlineOverrideMs = 500;
|
||||||
|
},
|
||||||
|
},
|
||||||
])("rejects $label with its stable diagnostic", async (fixture) => {
|
])("rejects $label with its stable diagnostic", async (fixture) => {
|
||||||
const { expectations, receipt } = await validFixture();
|
const { expectations, receipt } = await validFixture();
|
||||||
fixture.mutate(expectations, receipt);
|
fixture.mutate(expectations, receipt);
|
||||||
|
|||||||
Reference in New Issue
Block a user