feat: execute route and release recovery contracts
This commit is contained in:
@@ -15,6 +15,7 @@ describe("application input/output boundary", () => {
|
||||
"preferences",
|
||||
"diagnostics",
|
||||
"runtime",
|
||||
"recovery",
|
||||
]);
|
||||
expect(application).not.toHaveProperty("storage");
|
||||
expect(application).not.toHaveProperty("telemetry");
|
||||
@@ -51,8 +52,18 @@ describe("application input/output boundary", () => {
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: "hash-a",
|
||||
routeChunks: { "route-home": "assets/home.js" },
|
||||
}),
|
||||
refresh: async () => ({
|
||||
buildId: "build-a",
|
||||
releaseId: "release-a",
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: "hash-a",
|
||||
routeChunks: { "route-home": "assets/home.js" },
|
||||
}),
|
||||
},
|
||||
navigation: { reload: () => {} },
|
||||
} satisfies ApplicationOutputPorts;
|
||||
const application = createApplication(ports);
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createApplication } from "../../src/application/create-application.js";
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
|
||||
import type { StoragePort } from "../../src/application/ports/storage-port.js";
|
||||
|
||||
type ReleaseFixture = {
|
||||
buildId: string;
|
||||
releaseId: string;
|
||||
configSchemaVersion: string;
|
||||
apiContractVersion: string;
|
||||
assetManifestHash: string;
|
||||
routeChunks: Record<string, string>;
|
||||
};
|
||||
|
||||
function release(buildId: string, releaseId: string): ReleaseFixture {
|
||||
return {
|
||||
buildId,
|
||||
releaseId,
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: `${buildId}-assets`,
|
||||
routeChunks: { "route-home": `assets/${buildId}-home.js` },
|
||||
};
|
||||
}
|
||||
|
||||
function memoryStorage(): StoragePort {
|
||||
let value: unknown;
|
||||
return {
|
||||
read: () => ({ ok: true, value }),
|
||||
write: (_name, next) => {
|
||||
value = next;
|
||||
return { ok: true };
|
||||
},
|
||||
remove: () => ({ ok: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function applicationWith(options: {
|
||||
storage?: StoragePort;
|
||||
refresh?: () => Promise<ReturnType<typeof release>>;
|
||||
reload?: () => void;
|
||||
}) {
|
||||
const current = release("build-a", "release-a");
|
||||
return createApplication({
|
||||
session: createAnonymousSessionAdapter(),
|
||||
preferences: options.storage ?? memoryStorage(),
|
||||
diagnostics: { emit: () => {} },
|
||||
releaseInfo: {
|
||||
getCurrent: async () => current,
|
||||
refresh:
|
||||
options.refresh ??
|
||||
(async () => release("build-b", "release-b")),
|
||||
},
|
||||
navigation: { reload: options.reload ?? (() => {}) },
|
||||
});
|
||||
}
|
||||
|
||||
describe("production chunk recovery application input", () => {
|
||||
it("reloads exactly once for one active build/release pair", async () => {
|
||||
const reload = vi.fn();
|
||||
const application = applicationWith({ reload });
|
||||
const input = {
|
||||
chunkId: "route-home",
|
||||
failureKind: "CHUNK_LOAD_FAILURE" as const,
|
||||
};
|
||||
|
||||
await expect(application.recovery.recoverChunk(input)).resolves.toEqual({
|
||||
action: "reload-once",
|
||||
releasePair: "build-a/release-a->build-b/release-b",
|
||||
});
|
||||
await expect(application.recovery.recoverChunk(input)).resolves.toEqual({
|
||||
action: "support",
|
||||
reason: "reload-already-attempted",
|
||||
});
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
{
|
||||
refresh: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
},
|
||||
"manifest-unavailable",
|
||||
],
|
||||
[
|
||||
{
|
||||
refresh: async () => ({
|
||||
...release("build-b", "release-b"),
|
||||
routeChunks: {},
|
||||
}),
|
||||
},
|
||||
"active-chunk-unknown",
|
||||
],
|
||||
[
|
||||
{
|
||||
storage: {
|
||||
read: () => ({
|
||||
ok: false as const,
|
||||
error: {
|
||||
kind: "STORAGE_UNAVAILABLE",
|
||||
code: "STORAGE_UNAVAILABLE",
|
||||
retryable: false,
|
||||
operationId: "STORAGE",
|
||||
attemptCount: 1,
|
||||
userMessageKey: "error.storage_unavailable",
|
||||
action: "none" as const,
|
||||
},
|
||||
}),
|
||||
write: () => ({ ok: true as const }),
|
||||
remove: () => ({ ok: true as const }),
|
||||
},
|
||||
},
|
||||
"guard-read-failed",
|
||||
],
|
||||
])("fails closed for recovery dependency case %#", async (options, reason) => {
|
||||
const reload = vi.fn();
|
||||
const application = applicationWith({ ...options, reload });
|
||||
await expect(
|
||||
application.recovery.recoverChunk({
|
||||
chunkId: "route-home",
|
||||
failureKind: "CHUNK_LOAD_FAILURE",
|
||||
}),
|
||||
).resolves.toEqual({ action: "support", reason });
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -22,12 +22,14 @@ describe("controlled chunk recovery", () => {
|
||||
failureKind: "CHUNK_LOAD_FAILURE",
|
||||
manifestLoaded: true,
|
||||
currentBuildId: "build-a",
|
||||
currentReleaseId: "release-a",
|
||||
activeBuildId: "build-b",
|
||||
activeReleaseId: "release-b",
|
||||
storage,
|
||||
};
|
||||
expect(decideChunkRecovery(input)).toEqual({
|
||||
action: "reload-once",
|
||||
releasePair: "build-a->release-b",
|
||||
releasePair: "build-a/release-a->build-b/release-b",
|
||||
});
|
||||
expect(decideChunkRecovery(input)).toEqual({
|
||||
action: "support",
|
||||
@@ -38,12 +40,17 @@ describe("controlled chunk recovery", () => {
|
||||
it.each([
|
||||
[{ failureKind: "SERVER_FAILURE" }, "not-recoverable"],
|
||||
[{ manifestLoaded: false }, "manifest-unavailable"],
|
||||
[{ activeReleaseId: "build-a" }, "same-release"],
|
||||
[
|
||||
{ activeBuildId: "build-a", activeReleaseId: "release-a" },
|
||||
"same-release",
|
||||
],
|
||||
])("stops when a recovery invariant fails: %#", (override, reason) => {
|
||||
const result = decideChunkRecovery({
|
||||
failureKind: "DEPLOY_MISMATCH",
|
||||
manifestLoaded: true,
|
||||
currentBuildId: "build-a",
|
||||
currentReleaseId: "release-a",
|
||||
activeBuildId: "build-b",
|
||||
activeReleaseId: "release-b",
|
||||
storage: memoryStorage(),
|
||||
...override,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
describe("frontend failure classification", () => {
|
||||
it("defines all 26 stable error kinds with the seven contract fields", () => {
|
||||
expect(Object.keys(ERROR_REGISTRY)).toHaveLength(26);
|
||||
expect(Object.keys(ERROR_REGISTRY)).toHaveLength(31);
|
||||
for (const definition of Object.values(ERROR_REGISTRY)) {
|
||||
expect(definition).toEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("route registry", () => {
|
||||
"loadingSurface": "none",
|
||||
"navigationLabel": null,
|
||||
"navigationOrder": null,
|
||||
"paramsSchema": null,
|
||||
"paramsSchema": "NotFoundSplat",
|
||||
"path": "*",
|
||||
"routeId": "NOT_FOUND",
|
||||
"searchSchema": null,
|
||||
@@ -121,9 +121,12 @@ describe("route registry", () => {
|
||||
});
|
||||
|
||||
it("allows at most one automatic redirect per source-target pair", () => {
|
||||
const guard = createRedirectLoopGuard();
|
||||
const guard = createRedirectLoopGuard(2);
|
||||
expect(guard.allow("/private", "/signin")).toBe(true);
|
||||
expect(guard.allow("/private", "/signin")).toBe(false);
|
||||
expect(guard.allow("/signin", "/signin")).toBe(false);
|
||||
expect(guard.allow("/signin", "/continue")).toBe(true);
|
||||
expect(guard.allow("/continue", "/final")).toBe(false);
|
||||
expect(guard.hopCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("registry governance manifest", () => {
|
||||
it("declares exactly eight single-owner registries and impact labels", async () => {
|
||||
it("declares exactly nine single-owner registries and impact labels", async () => {
|
||||
const governance = JSON.parse(
|
||||
await readFile("config/contracts/registry-governance.json", "utf8"),
|
||||
);
|
||||
@@ -10,9 +10,9 @@ describe("registry governance manifest", () => {
|
||||
/** @type {Array<{registryId: string, owner: string}>} */ (
|
||||
governance.registries
|
||||
);
|
||||
expect(governance.registries).toHaveLength(8);
|
||||
expect(governance.registries).toHaveLength(9);
|
||||
expect(new Set(registries.map((entry) => entry.registryId)).size).toBe(
|
||||
8,
|
||||
9,
|
||||
);
|
||||
expect(registries.every((entry) => entry.owner)).toBe(true);
|
||||
expect(governance.compatibilityImpact.allowed).toEqual([
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ROUTE_RUNTIME_CONTRACT } from "../../src/contracts/route-runtime-contract.js";
|
||||
import { ROUTE_REGISTRY } from "../../src/contracts/routes.js";
|
||||
import {
|
||||
buildRouteUrl,
|
||||
parseRouteInput,
|
||||
} from "../../src/presentation/routes/route-codecs.js";
|
||||
import { ROUTE_RUNTIME } from "../../src/presentation/routes/route-runtime.js";
|
||||
|
||||
describe("typed route contract and runtime", () => {
|
||||
it("keeps contract, runtime contribution, and executable module complete", () => {
|
||||
expect(Object.keys(ROUTE_RUNTIME_CONTRACT).sort()).toEqual(
|
||||
Object.keys(ROUTE_REGISTRY).sort(),
|
||||
);
|
||||
expect(Object.keys(ROUTE_RUNTIME).sort()).toEqual(
|
||||
Object.keys(ROUTE_REGISTRY).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips canonical search through the registered codec", () => {
|
||||
const url = buildRouteUrl("SAMPLE_RESOURCE_LIST", {
|
||||
search: {
|
||||
tags: ["open", "new"],
|
||||
cursor: "a/b",
|
||||
limit: 5,
|
||||
},
|
||||
});
|
||||
expect(url).toBe(
|
||||
"/sample/resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
|
||||
);
|
||||
const parsedUrl = new URL(url, "https://app.test");
|
||||
expect(
|
||||
parseRouteInput(
|
||||
"SAMPLE_RESOURCE_LIST",
|
||||
{},
|
||||
parsedUrl.searchParams,
|
||||
),
|
||||
).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
routeId: "SAMPLE_RESOURCE_LIST",
|
||||
params: {},
|
||||
search: {
|
||||
cursor: "a/b",
|
||||
limit: 5,
|
||||
tags: ["open", "new"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown search and accepts the not-found splat owner", () => {
|
||||
expect(
|
||||
parseRouteInput(
|
||||
"SAMPLE_RESOURCE_LIST",
|
||||
{},
|
||||
new URLSearchParams("unknown=value"),
|
||||
),
|
||||
).toEqual({ success: false, code: "ROUTE_SEARCH_INVALID" });
|
||||
expect(
|
||||
parseRouteInput(
|
||||
"NOT_FOUND",
|
||||
{ "*": "missing/path" },
|
||||
new URLSearchParams(),
|
||||
),
|
||||
).toMatchObject({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ const runtime = {
|
||||
AUTH_MODE: "demo",
|
||||
REQUEST_TIMEOUT_MS: 4321,
|
||||
MAX_RETRY_ATTEMPTS: 0,
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
},
|
||||
};
|
||||
const release = /** @type {const} */ ({
|
||||
@@ -25,6 +26,7 @@ const release = /** @type {const} */ ({
|
||||
assetManifestHash: "hash-a",
|
||||
releaseId: "release-a",
|
||||
builtAt: "2026-07-25T00:00:00Z",
|
||||
routeChunks: { "route-home": "assets/home.js" },
|
||||
});
|
||||
|
||||
describe("runtime adapter composition", () => {
|
||||
@@ -59,6 +61,34 @@ describe("runtime adapter composition", () => {
|
||||
expect(adapters.outputPorts.session.getState()).toBe("integration-failed");
|
||||
});
|
||||
|
||||
it("refetches the active release manifest with no-store semantics", async () => {
|
||||
const activeRelease = {
|
||||
...release,
|
||||
buildId: "build-b",
|
||||
releaseId: "release-b",
|
||||
routeChunks: { "route-home": "assets/home-b.js" },
|
||||
};
|
||||
const fetcher = vi.fn(async () => Response.json(activeRelease));
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime:
|
||||
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ (
|
||||
runtime
|
||||
),
|
||||
release,
|
||||
fetcher,
|
||||
host: {},
|
||||
});
|
||||
|
||||
await expect(adapters.outputPorts.releaseInfo.refresh()).resolves.toMatchObject({
|
||||
buildId: "build-b",
|
||||
releaseId: "release-b",
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledWith("/release-manifest.json", {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("injects runtime timeout and max-attempt policy into HTTP execution", async () => {
|
||||
const scheduled =
|
||||
/** @type {Array<{callback: () => void, milliseconds: number}>} */ ([]);
|
||||
|
||||
Reference in New Issue
Block a user