Files
clean-architecture-frontend…/tests/integration/http-execution-v3-observability.test.ts
T
DongHyeonkaandClaude Opus 5 10a04d3695 fix: let a removal fixture actually build the thing it claims still builds
FE-GATE-020 proves a capability can be removed by rebuilding the whole project
without it. The fixture it built could not get that far, and the failures all
came from the fixture rather than from anything about removability.

It was not a repository. The supply-chain inventory is defined as the tracked
file set, so it asks `git ls-files` what the project contains; with no
repository to ask, generation failed and took every provider suite down with
it. It is now initialised on preparation and committed after the removal — not
before, or the index would still list the files the removal deleted.

It had no `.gitignore`, so once it did have a repository, every generated
artifact and every linked module landed in the index and the inventory refused
the fixture for tracked and generated paths colliding. It carries the ignore
rules now, and therefore records the same tracked set as the repository it was
copied from.

Each removal script kept its own copy-target list and they had drifted: the
reference-feature fixture omitted `playwright.capabilities.config.ts`, which the
inventory requires. There is one list now. It also gained the install and
workspace identity — `.npmrc`, the lockfile, the workspace file — without which
the fixture is a different project, and the release evidence a candidate is
assembled from, without which no candidate can be built at all.

A tracked root the removal deletes is no longer required of the result: the
optional-recipe fixture deletes `recipes/`, and the inventory policy demanded
it back. Roots that are gone are pruned from the fixture's policy.

Two smaller causes. A platform integration file asserted the reference feature's
own route ids, so removing the feature left it importing a deleted module —
typecheck, the test run, coverage and the residue scan all failed on that one
misplaced assertion, which now lives in the feature's test tree. And the
canonical exact-count authority was re-imposed on a contract the fixture
deliberately reduces, failing the fixture for the reduction it exists to prove;
`CI_CONTRACT_MODE` already marked those runs and is now honoured by default.

The reference-feature fixture goes from failing before its first assertion to
1,612 passing with one failure, and that one is the live process-tree
observation test already red on the main tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:34:28 +09:00

285 lines
8.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
import type {
HttpExecutionObservation,
} from "../../src/adapters/http/http-execution-v3.ts";
import { createHttpObservationProjector } from "../../src/bootstrap/runtime-adapters.ts";
import {
DIAGNOSTIC_CONTEXT_ALLOWLIST,
projectDiagnosticRecord,
type DiagnosticRecordInput,
} from "../../src/contracts/diagnostics.ts";
import {
projectTelemetryEvent,
type TelemetryEventName,
} from "../../src/contracts/telemetry.ts";
import {
TEST_CREATE_HTTP_CONTRACT,
TEST_LIST_HTTP_CONTRACT,
} from "../helpers/external-contract-fixture.ts";
const ROUTE_ID = "TEST_ROUTE";
function scopeSnapshot(isCurrent: () => boolean = () => true) {
return Object.freeze({
generation: 1,
fingerprint: "scope-1",
identities: Object.freeze({}) as never,
signal: new AbortController().signal,
isCurrent,
});
}
type RecordedDiagnostic = DiagnosticRecordInput;
type RecordedTelemetry = Readonly<{
eventName: TelemetryEventName;
attributes: Readonly<Record<string, unknown>>;
}>;
function recordingSinks() {
const diagnostics: RecordedDiagnostic[] = [];
const telemetry: RecordedTelemetry[] = [];
return {
diagnostics,
telemetry,
projector: createHttpObservationProjector({
diagnostics: {
record(input: DiagnosticRecordInput) {
diagnostics.push(input);
},
},
telemetry: {
emit(
eventName: TelemetryEventName,
attributes: Record<string, unknown>,
) {
telemetry.push(Object.freeze({ eventName, attributes }));
},
},
}),
};
}
describe("V3 HTTP observability projection", () => {
it("projects every V3 terminal outcome through the closed diagnostics allowlist", async () => {
const sinks = recordingSinks();
const cases: readonly Readonly<{
label: string;
fetcher: typeof fetch;
}>[] = [
{
label: "SUCCESS",
fetcher: (async () =>
Response.json([{ id: "a", name: "A" }])) as unknown as typeof fetch,
},
{
label: "PROBLEM",
fetcher: (async () =>
Response.json(
{ type: "about:blank", title: "nope", status: 400 },
{ status: 400 },
)) as unknown as typeof fetch,
},
{
label: "TRANSPORT_FAILURE",
fetcher: (async () => {
throw new TypeError("network down");
}) as unknown as typeof fetch,
},
{
label: "CONTRACT_VIOLATION",
fetcher: (async () =>
new Response("<html/>", {
status: 200,
headers: { "content-type": "text/html" },
})) as unknown as typeof fetch,
},
];
for (const testCase of cases) {
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
}),
fetcher: testCase.fetcher,
observe: sinks.projector,
});
await executor.execute(
TEST_LIST_HTTP_CONTRACT,
{ limit: 5 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
}
expect(sinks.diagnostics).toHaveLength(cases.length);
for (const recorded of sinks.diagnostics) {
expect(recorded.eventId).toBe("http.request.completed");
const contextKeys = Object.keys(recorded.context ?? {});
expect(contextKeys).toEqual(
expect.arrayContaining([
"route_id",
"operation_id",
"operation",
"outcome",
"error_kind",
"http_status_group",
"attempt_count_bucket",
"duration_bucket",
]),
);
for (const key of contextKeys) {
expect(DIAGNOSTIC_CONTEXT_ALLOWLIST).toContain(key);
}
expect(contextKeys).not.toContain("attempts");
expect(contextKeys).not.toContain("certainty");
const projection = projectDiagnosticRecord(recorded);
expect(projection.success).toBe(true);
}
});
it("emits one failure telemetry event for a non-abort terminal failure", async () => {
const sinks = recordingSinks();
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
}),
fetcher: (async () => {
throw new TypeError("network down");
}) as unknown as typeof fetch,
observe: sinks.projector,
});
const outcome = await executor.execute(
TEST_LIST_HTTP_CONTRACT,
{ limit: 5 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
expect(outcome.kind).toBe("TRANSPORT_FAILURE");
expect(sinks.telemetry).toHaveLength(1);
const emitted = sinks.telemetry[0];
expect(emitted?.eventName).toBe("api.request.failed");
expect(emitted?.attributes).toMatchObject({
route_id: ROUTE_ID,
operation_id: "TEST_LIST_ENTITIES",
error_kind: "NETWORK_FAILURE",
http_status_group: "none",
attempt_count_bucket: "1",
});
const projected = projectTelemetryEvent(
emitted?.eventName ?? "api.request.failed",
emitted?.attributes ?? {},
);
expect(projected.success).toBe(true);
expect(sinks.diagnostics).toHaveLength(1);
});
it("does not emit failure telemetry for caller cancellation or scope fencing", async () => {
const cancelled = recordingSinks();
const callerController = new AbortController();
callerController.abort();
const cancelledExecutor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
}),
fetcher: (async () =>
Response.json([{ id: "a", name: "A" }])) as unknown as typeof fetch,
observe: cancelled.projector,
});
const cancelledOutcome = await cancelledExecutor.execute(
TEST_LIST_HTTP_CONTRACT,
{ limit: 5 },
{
routeId: ROUTE_ID,
scope: scopeSnapshot(),
signal: callerController.signal,
},
);
expect(cancelledOutcome.kind).toBe("CANCELLED");
expect(cancelled.diagnostics).toHaveLength(1);
expect(cancelled.telemetry).toHaveLength(0);
const fenced = recordingSinks();
const fencedExecutor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
}),
fetcher: (async () =>
Response.json([{ id: "a", name: "A" }])) as unknown as typeof fetch,
observe: fenced.projector,
});
const fencedOutcome = await fencedExecutor.execute(
TEST_LIST_HTTP_CONTRACT,
{ limit: 5 },
{ routeId: ROUTE_ID, scope: scopeSnapshot(() => false) },
);
expect(fencedOutcome.kind).toBe("CONTRACT_VIOLATION");
expect(fenced.diagnostics).toHaveLength(1);
expect(fenced.telemetry).toHaveLength(0);
});
it("cannot change the HTTP result when diagnostics or telemetry throws", async () => {
const projector = createHttpObservationProjector({
diagnostics: {
record() {
throw new Error("diagnostics sink exploded");
},
},
telemetry: {
emit() {
throw new Error("telemetry sink exploded");
},
},
});
const observe = vi.fn((observation: HttpExecutionObservation) => {
projector(observation);
});
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
}),
fetcher: (async () =>
Response.json({ id: "created", name: "Created" }, {
status: 201,
})) as unknown as typeof fetch,
observe,
});
const outcome = await executor.execute(
TEST_CREATE_HTTP_CONTRACT,
{ name: "Created" },
{
routeId: ROUTE_ID,
scope: scopeSnapshot(),
intent: Object.freeze({
intentId: "intent-1",
operationId: "TEST_CREATE_ENTITY",
canonicalInputIdentity: "opaque-input-identity",
idempotencyKey: "key-1",
createdAtMonotonicMs: 1,
}),
},
);
expect(outcome.kind).toBe("SUCCESS");
expect(observe).toHaveBeenCalledTimes(1);
});
});