refactor: 리펙토링
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
type ApplicationOutputPorts,
|
||||
} from "../../src/application/create-application.ts";
|
||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
declare module "../../src/application/ports/in/application-api.ts" {
|
||||
interface ApplicationFeatureInputs {
|
||||
@@ -97,6 +98,7 @@ describe("application input/output boundary", () => {
|
||||
routeChunks: { "route-home": "assets/home.js" },
|
||||
}),
|
||||
},
|
||||
runtimeCapabilities: createRuntimeCapabilitiesStub(),
|
||||
navigation: { reload: () => {} },
|
||||
} satisfies ApplicationOutputPorts;
|
||||
const application = createApplication(ports);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-
|
||||
import type { StoragePort } from "../../src/application/ports/storage-port.ts";
|
||||
import type { DiagnosticsPort } from "../../src/application/ports/diagnostics-port.ts";
|
||||
import type { TelemetryPort } from "../../src/application/ports/telemetry-port.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
type ReleaseFixture = {
|
||||
buildId: string;
|
||||
@@ -51,6 +52,7 @@ function applicationWith(options: {
|
||||
preferences: options.storage ?? memoryStorage(),
|
||||
diagnostics: options.diagnostics ?? { record: () => {} },
|
||||
telemetry: options.telemetry ?? { emit: () => {} },
|
||||
runtimeCapabilities: createRuntimeCapabilitiesStub(),
|
||||
releaseInfo: {
|
||||
getCurrent: async () => current,
|
||||
refresh:
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { classifyGateStepResult } from "../../scripts/lib/ci-step-result.ts";
|
||||
|
||||
describe("CI gate step result classification", () => {
|
||||
it("accepts a negative fixture only with its exact exit and diagnostic identity", () => {
|
||||
expect(
|
||||
classifyGateStepResult(
|
||||
{
|
||||
kind: "fail",
|
||||
expectedExitCode: 2,
|
||||
expectedDiagnosticId: "error TS2322:",
|
||||
},
|
||||
{
|
||||
status: 2,
|
||||
signal: null,
|
||||
stdout: "fixture.ts(1,1): error TS2322: incompatible type\n",
|
||||
stderr: "",
|
||||
},
|
||||
),
|
||||
).toEqual({ kind: "EXPECTED_FAILURE", expectationMet: true });
|
||||
});
|
||||
|
||||
it("rejects a negative fixture with the wrong non-zero exit code", () => {
|
||||
expect(
|
||||
classifyGateStepResult(
|
||||
{
|
||||
kind: "fail",
|
||||
expectedExitCode: 2,
|
||||
expectedDiagnosticId: "error TS2322:",
|
||||
},
|
||||
{
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: "fixture.ts(1,1): error TS2322: incompatible type\n",
|
||||
stderr: "",
|
||||
},
|
||||
),
|
||||
).toEqual({ kind: "UNEXPECTED_EXIT", expectationMet: false });
|
||||
});
|
||||
|
||||
it("rejects a negative fixture without the exact case-sensitive diagnostic identity", () => {
|
||||
expect(
|
||||
classifyGateStepResult(
|
||||
{
|
||||
kind: "fail",
|
||||
expectedExitCode: 2,
|
||||
expectedDiagnosticId: "error TS2322:",
|
||||
},
|
||||
{
|
||||
status: 2,
|
||||
signal: null,
|
||||
stdout: "fixture.ts(1,1): error ts2322: incompatible type\n",
|
||||
stderr: "",
|
||||
},
|
||||
),
|
||||
).toEqual({ kind: "UNEXPECTED_DIAGNOSTIC", expectationMet: false });
|
||||
});
|
||||
|
||||
it("matches an exact diagnostic identity emitted on stderr", () => {
|
||||
expect(
|
||||
classifyGateStepResult(
|
||||
{
|
||||
kind: "fail",
|
||||
expectedExitCode: 1,
|
||||
expectedDiagnosticId: "Risk coverage failed:",
|
||||
},
|
||||
{
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: "",
|
||||
stderr: "Risk coverage failed:\n- expected fixture finding\n",
|
||||
},
|
||||
),
|
||||
).toEqual({ kind: "EXPECTED_FAILURE", expectationMet: true });
|
||||
});
|
||||
|
||||
it.each(["ENOENT", "EACCES", "ETIMEDOUT", "ENOBUFS"])(
|
||||
"never treats spawn infrastructure error %s as an expected negative fixture",
|
||||
(code) => {
|
||||
expect(
|
||||
classifyGateStepResult(
|
||||
{
|
||||
kind: "fail",
|
||||
expectedExitCode: 1,
|
||||
expectedDiagnosticId: "fixture failed:",
|
||||
},
|
||||
{
|
||||
status: null,
|
||||
signal: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
error: { code },
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
kind: "INFRASTRUCTURE_FAILURE",
|
||||
expectationMet: false,
|
||||
detail: code,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("treats a code-less spawn error as infrastructure failure", () => {
|
||||
expect(
|
||||
classifyGateStepResult(
|
||||
{
|
||||
kind: "fail",
|
||||
expectedExitCode: 1,
|
||||
expectedDiagnosticId: "fixture failed:",
|
||||
},
|
||||
{
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: "",
|
||||
stderr: "fixture failed:\n",
|
||||
error: {},
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
kind: "INFRASTRUCTURE_FAILURE",
|
||||
expectationMet: false,
|
||||
detail: "SPAWN_ERROR",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a signal-terminated or status-less child as infrastructure failure", () => {
|
||||
expect(
|
||||
classifyGateStepResult(
|
||||
{
|
||||
kind: "fail",
|
||||
expectedExitCode: 1,
|
||||
expectedDiagnosticId: "fixture failed:",
|
||||
},
|
||||
{
|
||||
status: null,
|
||||
signal: "SIGTERM",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
kind: "INFRASTRUCTURE_FAILURE",
|
||||
expectationMet: false,
|
||||
detail: "SIGTERM",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts
|
||||
|
||||
function scope() {
|
||||
let current = true;
|
||||
const lifetime = new AbortController();
|
||||
return {
|
||||
snapshot: {
|
||||
generation: 3,
|
||||
@@ -12,10 +13,12 @@ function scope() {
|
||||
identities: createRuntimeIdentityRegistry({
|
||||
tokenFactory: () => crypto.randomUUID(),
|
||||
}),
|
||||
signal: lifetime.signal,
|
||||
isCurrent: () => current,
|
||||
},
|
||||
expire: () => {
|
||||
current = false;
|
||||
lifetime.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../../src/contracts/cache-invalidation.ts";
|
||||
import {
|
||||
createBrowserCrossContextInvalidation,
|
||||
createBrowserCrossContextInvalidationFromHost,
|
||||
type BroadcastChannelFacade,
|
||||
type BroadcastMessageListener,
|
||||
type BrowserCrossContextInvalidationDependencies,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
|
||||
const NOW = 1_000_000;
|
||||
const CACHE_EPOCH = "cache-epoch-0001";
|
||||
const TOPIC = "reference-resources";
|
||||
const TOPIC = "sample-topic-alpha";
|
||||
const CHANNEL_NAME = "cache-invalidation-v1";
|
||||
const STORAGE_PULSE_KEY = "ca-frontend:cache-invalidation:v1:pulse";
|
||||
|
||||
@@ -307,6 +308,30 @@ describe("cache invalidation wire contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser cross-context host", () => {
|
||||
it("does not inspect browser capabilities when no topic is installed", () => {
|
||||
const reads: string[] = [];
|
||||
const host = new Proxy<Record<string, unknown>>(
|
||||
{},
|
||||
{
|
||||
get(_target, property) {
|
||||
reads.push(String(property));
|
||||
throw new DOMException("Capability access denied", "SecurityError");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
createBrowserCrossContextInvalidationFromHost({
|
||||
host,
|
||||
cacheEpoch: CACHE_EPOCH,
|
||||
topics: [],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(reads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser cross-context invalidation transport", () => {
|
||||
it("publishes one exact BroadcastChannel event and filters self echo", () => {
|
||||
const network = new FakeBroadcastNetwork();
|
||||
@@ -525,7 +550,7 @@ describe("browser cross-context invalidation transport", () => {
|
||||
true,
|
||||
);
|
||||
expect(JSON.stringify(observations)).not.toMatch(
|
||||
/sensitive-event-identifier|reference-resources|cache-epoch-0001/,
|
||||
/sensitive-event-identifier|sample-topic-alpha|cache-epoch-0001/,
|
||||
);
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ContractContributionError,
|
||||
composeContractContributions,
|
||||
type InstalledContractContribution,
|
||||
} from "../../src/contracts/external-contract-runtime.ts";
|
||||
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
|
||||
|
||||
function contribution(
|
||||
contributionId: string,
|
||||
http: InstalledContractContribution["http"],
|
||||
): InstalledContractContribution & Readonly<{ contributionId: string }> {
|
||||
return Object.freeze({
|
||||
...REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION,
|
||||
contributionId,
|
||||
http: Object.freeze([...http]),
|
||||
});
|
||||
}
|
||||
|
||||
function captureContributionError(operation: () => unknown): ContractContributionError {
|
||||
try {
|
||||
operation();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ContractContributionError);
|
||||
return error as ContractContributionError;
|
||||
}
|
||||
throw new Error("Expected contract composition to fail.");
|
||||
}
|
||||
|
||||
describe("external contract contribution composition", () => {
|
||||
it("allows one feature to install multiple uniquely identified contributions", () => {
|
||||
const operations = REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http;
|
||||
const composed = composeContractContributions([
|
||||
contribution("reference-read-contracts", operations.slice(0, 2)),
|
||||
contribution("reference-command-contracts", operations.slice(2)),
|
||||
]);
|
||||
|
||||
expect(composed.contributions).toHaveLength(2);
|
||||
expect(composed.httpByOperationId.size).toBe(3);
|
||||
});
|
||||
|
||||
it("rejects duplicate contribution identities even across different feature entries", () => {
|
||||
const first = contribution(
|
||||
"reference-contracts",
|
||||
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.slice(0, 1),
|
||||
);
|
||||
const second = contribution(
|
||||
"reference-contracts",
|
||||
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.slice(1),
|
||||
);
|
||||
|
||||
const error = captureContributionError(() =>
|
||||
composeContractContributions([first, second]),
|
||||
);
|
||||
expect(error.reason).toContain("duplicate contributionId");
|
||||
});
|
||||
|
||||
it("maps malformed source values to the closed composition error", () => {
|
||||
const malformed = {
|
||||
...contribution("malformed-source", []),
|
||||
source: null,
|
||||
} as unknown as InstalledContractContribution;
|
||||
|
||||
const error = captureContributionError(() =>
|
||||
composeContractContributions([malformed]),
|
||||
);
|
||||
expect(error.reason).toContain("source");
|
||||
});
|
||||
|
||||
it("rejects method and body vocabulary outside the runtime protocol", () => {
|
||||
const installed = REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http[0]!;
|
||||
const malformedOperation = {
|
||||
...installed,
|
||||
contract: { ...installed.contract, method: "TRACE" },
|
||||
} as unknown as InstalledContractContribution["http"][number];
|
||||
|
||||
const error = captureContributionError(() =>
|
||||
composeContractContributions([
|
||||
contribution("invalid-http-vocabulary", [malformedOperation]),
|
||||
]),
|
||||
);
|
||||
expect(error.reason).toContain("method");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
|
||||
import type { InstalledHttpContract } from "../../src/contracts/external-contract-runtime.ts";
|
||||
import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../../src/features/installed-contract-contributions.ts";
|
||||
|
||||
const installed = (() => {
|
||||
const candidate = COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
);
|
||||
if (!candidate) throw new Error("reference list contract is not installed");
|
||||
return candidate;
|
||||
})();
|
||||
|
||||
const scope = Object.freeze({
|
||||
generation: 1,
|
||||
fingerprint: "scope-1",
|
||||
identities: Object.freeze({}) as never,
|
||||
signal: new AbortController().signal,
|
||||
isCurrent: () => true,
|
||||
});
|
||||
|
||||
const createInstalled = (() => {
|
||||
const candidate = COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
);
|
||||
if (!candidate) throw new Error("reference create contract is not installed");
|
||||
return candidate;
|
||||
})();
|
||||
|
||||
function operation(
|
||||
overrides: Readonly<{
|
||||
deadlineMs?: number;
|
||||
responseBody?: "REQUIRED_JSON" | "OPTIONAL_JSON" | "NONE";
|
||||
}> = {},
|
||||
): InstalledHttpContract<unknown, unknown, unknown> {
|
||||
return {
|
||||
...installed,
|
||||
contract: {
|
||||
...installed.contract,
|
||||
responseBody: overrides.responseBody ?? installed.contract.responseBody,
|
||||
},
|
||||
frontend: {
|
||||
...installed.frontend,
|
||||
totalDeadlineMs: overrides.deadlineMs ?? installed.frontend.totalDeadlineMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
it.each([
|
||||
[{}, "limit=20"],
|
||||
[{ limit: "7" }, "limit=7"],
|
||||
])("projects the canonical validated input %#", async (input, expectedQuery) => {
|
||||
const fetcher = vi.fn(async () => Response.json([]));
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 0,
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher,
|
||||
});
|
||||
|
||||
await expect(executor.execute(installed, input, { scope })).resolves.toMatchObject({
|
||||
kind: "SUCCESS",
|
||||
});
|
||||
expect(String((fetcher.mock.calls as unknown[][])[0]?.[0])).toContain(
|
||||
expectedQuery,
|
||||
);
|
||||
});
|
||||
|
||||
it("contains throwing and malformed external request projections", async () => {
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 0,
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: vi.fn(),
|
||||
});
|
||||
const throwing = {
|
||||
...installed,
|
||||
contract: {
|
||||
...installed.contract,
|
||||
projectRequest: () => {
|
||||
throw new Error("external descriptor defect");
|
||||
},
|
||||
},
|
||||
};
|
||||
const malformed = {
|
||||
...installed,
|
||||
contract: {
|
||||
...installed.contract,
|
||||
projectRequest: () => ({ pathValues: {}, queryEntries: [["limit"]], body: null }),
|
||||
},
|
||||
} as unknown as typeof installed;
|
||||
|
||||
await expect(executor.execute(throwing, { limit: 20 }, { scope })).resolves.toMatchObject({
|
||||
kind: "CONTRACT_VIOLATION",
|
||||
effect: "NOT_APPLICABLE",
|
||||
});
|
||||
await expect(executor.execute(malformed, { limit: 20 }, { scope })).resolves.toMatchObject({
|
||||
kind: "CONTRACT_VIOLATION",
|
||||
effect: "NOT_APPLICABLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves MAYBE_APPLIED for a malformed command response after dispatch", async () => {
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 0,
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: vi.fn(async () => Response.json({ malformed: true }, { status: 201 })),
|
||||
});
|
||||
|
||||
await expect(
|
||||
executor.execute(
|
||||
createInstalled,
|
||||
{ name: "created" },
|
||||
{
|
||||
scope,
|
||||
intent: { intentId: "intent-1", startedBy: "USER", idempotencyKey: "key-1" },
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
kind: "CONTRACT_VIOLATION",
|
||||
violation: { kind: "SUCCESS_SCHEMA_INVALID" },
|
||||
effect: "MAYBE_APPLIED",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves MAYBE_APPLIED when a command response arrives after its scope fence", async () => {
|
||||
let current = true;
|
||||
const lifetime = new AbortController();
|
||||
const fencedScope = Object.freeze({
|
||||
...scope,
|
||||
signal: lifetime.signal,
|
||||
isCurrent: () => current,
|
||||
});
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 0,
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: vi.fn(async () => {
|
||||
current = false;
|
||||
lifetime.abort();
|
||||
return Response.json(
|
||||
{ id: "created", name: "Created" },
|
||||
{ status: 201 },
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
executor.execute(
|
||||
createInstalled,
|
||||
{ name: "created" },
|
||||
{
|
||||
scope: fencedScope,
|
||||
intent: { intentId: "intent-2", startedBy: "USER", idempotencyKey: "key-2" },
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
kind: "CONTRACT_VIOLATION",
|
||||
violation: { kind: "SCOPE_FENCED" },
|
||||
effect: "MAYBE_APPLIED",
|
||||
});
|
||||
});
|
||||
|
||||
it("settles a credential hang at the total operation deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
let settled = false;
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 0,
|
||||
attachCredentials: () => new Promise(() => {}),
|
||||
});
|
||||
|
||||
const result = executor
|
||||
.execute(operation({ deadlineMs: 5 }), { limit: 20 }, { scope })
|
||||
.then((outcome) => {
|
||||
settled = true;
|
||||
return outcome;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(settled).toBe(true);
|
||||
await expect(result).resolves.toMatchObject({
|
||||
kind: "TRANSPORT_FAILURE",
|
||||
failure: { kind: "TIMEOUT" },
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("cancels a non-cooperative retry sleep when the caller aborts", async () => {
|
||||
const caller = new AbortController();
|
||||
let sleepSignal: AbortSignal | undefined;
|
||||
let settled = false;
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 2,
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: vi.fn(async () =>
|
||||
Response.json(
|
||||
{ type: "about:blank", title: "temporary", status: 503 },
|
||||
{ status: 503 },
|
||||
),
|
||||
),
|
||||
sleep: (_ms, signal) => {
|
||||
sleepSignal = signal;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
const result = executor
|
||||
.execute(installed, { limit: 20 }, { scope, signal: caller.signal })
|
||||
.then((outcome) => {
|
||||
settled = true;
|
||||
return outcome;
|
||||
});
|
||||
await vi.waitFor(() => expect(sleepSignal).toBeDefined());
|
||||
caller.abort();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(sleepSignal?.aborted).toBe(true);
|
||||
expect(settled).toBe(true);
|
||||
await expect(result).resolves.toMatchObject({ kind: "CANCELLED" });
|
||||
});
|
||||
|
||||
it("treats an unreadable forbidden-body probe as a transport failure", async () => {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.error(new TypeError("stream failed"));
|
||||
},
|
||||
});
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 0,
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: vi.fn(async () => new Response(body, { status: 200 })),
|
||||
});
|
||||
|
||||
await expect(
|
||||
executor.execute(
|
||||
operation({ responseBody: "NONE" }),
|
||||
{ limit: 20 },
|
||||
{ scope },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
kind: "TRANSPORT_FAILURE",
|
||||
failure: { kind: "RESPONSE_STREAM_FAILURE" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -380,6 +380,48 @@ describe("IndexedDB runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shares one native open request across concurrent callers", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const nativeOpen = vi.spyOn(memory.factory, "open");
|
||||
const runtime = createIndexedDbRuntime(dependencies(memory));
|
||||
|
||||
const results = await Promise.all([
|
||||
runtime.open(),
|
||||
runtime.open(),
|
||||
runtime.open(),
|
||||
]);
|
||||
|
||||
expect(nativeOpen).toHaveBeenCalledOnce();
|
||||
expect(results).toEqual([
|
||||
{ ok: true, value: undefined },
|
||||
{ ok: true, value: undefined },
|
||||
{ ok: true, value: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("isolates a caller abort from the shared native open request", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
memory.blockNextOpen();
|
||||
const nativeOpen = vi.spyOn(memory.factory, "open");
|
||||
const runtime = createIndexedDbRuntime(dependencies(memory));
|
||||
const cancelledCaller = new AbortController();
|
||||
|
||||
const surviving = runtime.open();
|
||||
const cancelled = runtime.open(cancelledCaller.signal);
|
||||
await Promise.resolve();
|
||||
cancelledCaller.abort();
|
||||
|
||||
expect(await cancelled).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED", operation: "INDEXEDDB_OPEN" },
|
||||
});
|
||||
expect(nativeOpen).toHaveBeenCalledOnce();
|
||||
|
||||
memory.releaseBlockedOpen();
|
||||
expect(await surviving).toEqual({ ok: true, value: undefined });
|
||||
expect(runtime.getStatus()).toEqual({ kind: "READY", schemaVersion: 1 });
|
||||
});
|
||||
|
||||
it("times out a blocked upgrade, then closes a late successful connection", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
memory.blockNextOpen();
|
||||
@@ -422,6 +464,50 @@ describe("IndexedDB runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retains native open ownership after a blocked timeout until late success", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
memory.blockNextOpen();
|
||||
const nativeOpen = vi.spyOn(memory.factory, "open");
|
||||
let timeout: (() => void) | undefined;
|
||||
const runtime = createIndexedDbRuntime(
|
||||
dependencies(memory, {
|
||||
blockedTimeoutMs: 25,
|
||||
scheduler: {
|
||||
setTimeout: (callback) => {
|
||||
timeout = callback;
|
||||
return "blocked-timer";
|
||||
},
|
||||
clearTimeout: () => undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const first = runtime.open();
|
||||
await Promise.resolve();
|
||||
timeout?.();
|
||||
await expect(first).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "BLOCKED" },
|
||||
});
|
||||
|
||||
const second = runtime.open();
|
||||
await Promise.resolve();
|
||||
const nativeOpenCountBeforeLateSuccess = nativeOpen.mock.calls.length;
|
||||
memory.releaseBlockedOpen();
|
||||
|
||||
await expect(second).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "BLOCKED" },
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(nativeOpenCountBeforeLateSuccess).toBe(1);
|
||||
expect(memory.isConnectionClosed()).toBe(true);
|
||||
expect(runtime.getStatus()).toEqual({
|
||||
kind: "CLOSED",
|
||||
reason: "NOT_OPENED",
|
||||
});
|
||||
});
|
||||
|
||||
it("closes immediately on versionchange and isolates listener failures", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const onVersionChange = vi.fn(() => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { assertMatchesJsonSchema } from "../../scripts/lib/json-schema.ts";
|
||||
|
||||
async function json(path: string): Promise<unknown> {
|
||||
return JSON.parse(await readFile(path, "utf8")) as unknown;
|
||||
}
|
||||
|
||||
describe("checked-in JSON Schema execution", () => {
|
||||
it("accepts a build manifest and rejects undeclared output fields", async () => {
|
||||
const schema = await json("schemas/artifacts/build-manifest.schema.json");
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
buildId: "build-1",
|
||||
commitSha: "commit-1",
|
||||
releaseId: "release-1",
|
||||
moduleInventoryHash: "inventory-hash",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
buildContext: {
|
||||
nodeVersion: "v24.11.0",
|
||||
packageManagerVersion: "11.17.0",
|
||||
runnerImage: "test-runner",
|
||||
sourceDateEpoch: null,
|
||||
},
|
||||
outputs: {
|
||||
directory: "dist",
|
||||
viteManifest: "dist/.vite/manifest.json",
|
||||
moduleInventory: "artifacts/quality/vite-module-inventory.json",
|
||||
routeChunks: { home: "assets/home.js" },
|
||||
runtimeConfigSchema: "dist/runtime-config.schema.json",
|
||||
},
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
assertMatchesJsonSchema(schema, manifest, "build manifest"),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertMatchesJsonSchema(
|
||||
schema,
|
||||
{ ...manifest, undocumented: true },
|
||||
"build manifest",
|
||||
),
|
||||
).toThrow(/checked-in JSON Schema/u);
|
||||
});
|
||||
|
||||
it("resolves local schema definitions in the recipe catalog", async () => {
|
||||
const [schema, catalog] = await Promise.all([
|
||||
json("schemas/config/frontend-capability-recipes.schema.json"),
|
||||
json("config/recipes/frontend-capability-recipes.json"),
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
assertMatchesJsonSchema(schema, catalog, "optional recipe catalog"),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,15 @@ describe("installed route registry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("treats every non-public route as explicitly session-required", () => {
|
||||
expect(ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST.access).toBe(
|
||||
"session-required",
|
||||
);
|
||||
expect(
|
||||
decideRouteAccess("REFERENCE_RESOURCE_LIST", "unauthenticated"),
|
||||
).toEqual({ allowed: false, action: "show-sign-in" });
|
||||
});
|
||||
|
||||
it("bounds automatic redirects by pair and maximum hops", () => {
|
||||
const guard = createRedirectLoopGuard(2);
|
||||
expect(guard.allow("/private", "/signin")).toBe(true);
|
||||
@@ -36,5 +45,8 @@ describe("installed route registry", () => {
|
||||
expect(guard.allow("/signin", "/continue")).toBe(true);
|
||||
expect(guard.allow("/continue", "/final")).toBe(false);
|
||||
expect(guard.hopCount).toBe(2);
|
||||
|
||||
guard.reset();
|
||||
expect(guard.allow("/unrelated", "/canonical-unrelated")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -537,6 +537,87 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
});
|
||||
|
||||
describe("OPFS worker client lifecycle", () => {
|
||||
it("rejects a colliding concurrent request id without replacing the first RPC", async () => {
|
||||
let listener: ((event: MessageEvent<unknown>) => void) | undefined;
|
||||
const posted: OpfsWorkerRequest[] = [];
|
||||
const worker: OpfsWorkerLike = {
|
||||
postMessage(message) {
|
||||
posted.push(message);
|
||||
},
|
||||
addEventListener(_type, next) {
|
||||
listener = next;
|
||||
},
|
||||
removeEventListener() {
|
||||
listener = undefined;
|
||||
},
|
||||
};
|
||||
const gateway = createOpfsWorkerGateway({
|
||||
worker,
|
||||
policy: runtimePolicy,
|
||||
createRequestId: () => "request_collision_1234",
|
||||
});
|
||||
|
||||
const first = gateway.capabilities();
|
||||
const second = gateway.capabilities();
|
||||
expect(posted).toHaveLength(1);
|
||||
|
||||
listener?.({
|
||||
data: {
|
||||
requestId: "request_collision_1234",
|
||||
ok: true,
|
||||
value: {
|
||||
available: true,
|
||||
dedicatedWorkerRequired: true,
|
||||
crossContextMutationLockAvailable: true,
|
||||
synchronousAccessHandleAvailable: true,
|
||||
},
|
||||
},
|
||||
} as MessageEvent<unknown>);
|
||||
const secondResult = await second;
|
||||
gateway.close();
|
||||
await expect(first).resolves.toMatchObject({ ok: true });
|
||||
expect(secondResult).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects pending RPCs immediately when the worker reports a fatal event", async () => {
|
||||
let failureListener: ((event: Event) => void) | undefined;
|
||||
const worker: OpfsWorkerLike = {
|
||||
postMessage() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addFailureEventListener(listener) {
|
||||
failureListener = listener;
|
||||
},
|
||||
removeFailureEventListener() {
|
||||
failureListener = undefined;
|
||||
},
|
||||
};
|
||||
const gateway = createOpfsWorkerGateway({
|
||||
worker,
|
||||
policy: runtimePolicy,
|
||||
createRequestId: () => "request_crash_1234",
|
||||
});
|
||||
const pending = gateway.capabilities();
|
||||
|
||||
expect(failureListener).toBeTypeOf("function");
|
||||
if (!failureListener) {
|
||||
gateway.close();
|
||||
return;
|
||||
}
|
||||
failureListener(new Event("error"));
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE" },
|
||||
});
|
||||
await expect(gateway.capabilities()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes the open signal to verification and leaves the acquired source readable", async () => {
|
||||
let listener:
|
||||
| ((event: MessageEvent<unknown>) => void)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts
|
||||
|
||||
function scope() {
|
||||
let current = true;
|
||||
const lifetime = new AbortController();
|
||||
return {
|
||||
snapshot: {
|
||||
generation: 1,
|
||||
@@ -13,10 +14,12 @@ function scope() {
|
||||
identities: createRuntimeIdentityRegistry({
|
||||
tokenFactory: () => crypto.randomUUID(),
|
||||
}),
|
||||
signal: lifetime.signal,
|
||||
isCurrent: () => current,
|
||||
},
|
||||
expire: () => {
|
||||
current = false;
|
||||
lifetime.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createOptionalRuntimeHost } from "../../src/bootstrap/optional-runtime-host.ts";
|
||||
import type { ResolvedRuntimeCapabilities } from "../../src/contracts/runtime-capabilities.ts";
|
||||
import type {
|
||||
ServiceWorkerRuntimeHost,
|
||||
ServiceWorkerStartOutcome,
|
||||
} from "../../src/contracts/service-worker.ts";
|
||||
|
||||
const capabilities: ResolvedRuntimeCapabilities = Object.freeze({
|
||||
realtime: Object.freeze([]),
|
||||
webWorkers: Object.freeze([]),
|
||||
serviceWorker: null,
|
||||
serviceWorkerDisabledCleanup: false,
|
||||
offlineCommands: null,
|
||||
});
|
||||
|
||||
function deferred<Value>() {
|
||||
let resolve!: (value: Value) => void;
|
||||
const promise = new Promise<Value>((settle) => {
|
||||
resolve = settle;
|
||||
});
|
||||
return { promise, resolve } as const;
|
||||
}
|
||||
|
||||
describe("optional runtime host", () => {
|
||||
it("keeps stop-before-start terminal without acquiring browser resources", async () => {
|
||||
const serviceWorker: ServiceWorkerRuntimeHost = {
|
||||
start: vi.fn(
|
||||
async (): Promise<ServiceWorkerStartOutcome> => ({
|
||||
kind: "ACTIVE",
|
||||
buildId: "build-1",
|
||||
}),
|
||||
),
|
||||
requestActivation: vi.fn(),
|
||||
resetOwnedCaches: vi.fn(),
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
const addEventListener = vi.fn();
|
||||
const runtime = createOptionalRuntimeHost({
|
||||
capabilities,
|
||||
routerBasePath: "/",
|
||||
buildId: "build-1",
|
||||
serviceWorkerHost: serviceWorker,
|
||||
browserLifecycleHost: {
|
||||
addEventListener,
|
||||
removeEventListener: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.stop();
|
||||
await runtime.startAfterMount();
|
||||
|
||||
expect(runtime.browserLifecycle()).toBeNull();
|
||||
expect(runtime.health().serviceWorker).toBe("DISABLED");
|
||||
expect(addEventListener).not.toHaveBeenCalled();
|
||||
expect(serviceWorker.start).not.toHaveBeenCalled();
|
||||
expect(serviceWorker.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes stop behind an in-flight start and suppresses late health", async () => {
|
||||
const start = deferred<ServiceWorkerStartOutcome>();
|
||||
const serviceWorker: ServiceWorkerRuntimeHost = {
|
||||
start: vi.fn(() => start.promise),
|
||||
requestActivation: vi.fn(),
|
||||
resetOwnedCaches: vi.fn(),
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
const runtime = createOptionalRuntimeHost({
|
||||
capabilities,
|
||||
routerBasePath: "/",
|
||||
buildId: "build-1",
|
||||
serviceWorkerHost: serviceWorker,
|
||||
browserLifecycleHost: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const starting = runtime.startAfterMount();
|
||||
const stopping = runtime.stop();
|
||||
start.resolve({ kind: "ACTIVE", buildId: "build-1" });
|
||||
|
||||
await Promise.all([starting, stopping]);
|
||||
|
||||
expect(serviceWorker.stop).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.health().serviceWorker).toBe("DISABLED");
|
||||
expect(runtime.browserLifecycle()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { indexInvalidationRegistry } from "../../src/contracts/query-invalidation.ts";
|
||||
|
||||
describe("query invalidation registry", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "topic",
|
||||
registry: {
|
||||
topics: ["orders\u0000private"],
|
||||
namespaces: ["orders"],
|
||||
edges: [{ topicId: "orders\u0000private", namespace: "orders" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "namespace",
|
||||
registry: {
|
||||
topics: ["orders"],
|
||||
namespaces: ["orders\u001fprivate"],
|
||||
edges: [{ topicId: "orders", namespace: "orders\u001fprivate" }],
|
||||
},
|
||||
},
|
||||
])("rejects control characters in a registry $label", ({ registry }) => {
|
||||
expect(() => indexInvalidationRegistry(registry)).toThrow(/is invalid/u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BOOT_JSON_POLICIES,
|
||||
readBoundedBootJson,
|
||||
} from "../../src/bootstrap/read-bounded-boot-json.ts";
|
||||
|
||||
describe("bounded boot JSON admission", () => {
|
||||
it("does not call fetch when the caller signal is already aborted", async () => {
|
||||
const caller = new AbortController();
|
||||
caller.abort();
|
||||
let fetchCalls = 0;
|
||||
|
||||
const outcome = await readBoundedBootJson(
|
||||
"/config.json",
|
||||
BOOT_JSON_POLICIES.RUNTIME_CONFIG,
|
||||
{
|
||||
signal: caller.signal,
|
||||
fetcher: async () => {
|
||||
fetchCalls += 1;
|
||||
return new Response("{}", {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(fetchCalls).toBe(0);
|
||||
expect(outcome).toEqual({ ok: false, failure: "FETCH_FAILED" });
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,7 @@ export const TEST_MAPPER: InstalledBoundaryMapper = Object.freeze({
|
||||
mapperVersion: 1,
|
||||
inputSchemaId: "ReferenceRealtimePayload",
|
||||
outputContractId: "ReferenceRealtimeEvent",
|
||||
owner: "reference-feature",
|
||||
owner: "sample-owner",
|
||||
maxOutputItems: 1,
|
||||
map(input) {
|
||||
if (
|
||||
@@ -113,7 +113,7 @@ const snapshotOperation: ApiOperation = Object.freeze({
|
||||
requestSource: "none",
|
||||
requestSchema: "NoRequest",
|
||||
responseSchema: "ReferenceRealtimeCheckpoint",
|
||||
owner: "reference-feature",
|
||||
owner: "sample-owner",
|
||||
contractVersion: 2,
|
||||
protocol: "REST",
|
||||
semantics: "QUERY",
|
||||
@@ -161,7 +161,7 @@ export function createTestRealtimeRegistry(
|
||||
} as const);
|
||||
const eventType: RealtimeEventTypeRegistration = {
|
||||
id: EVENT_TYPE,
|
||||
owner: "reference-feature",
|
||||
owner: "sample-owner",
|
||||
payloadSchemaId: "ReferenceRealtimePayload",
|
||||
mapperId: "ReferenceRealtimeMapper",
|
||||
effectProfileId: EFFECT_PROFILE_ID,
|
||||
@@ -170,7 +170,7 @@ export function createTestRealtimeRegistry(
|
||||
const stream: RealtimeStreamRegistration = {
|
||||
id: STREAM_ID,
|
||||
protocol: "REALTIME_EVENT_V1",
|
||||
owner: "reference-feature",
|
||||
owner: "sample-owner",
|
||||
scope: "ACCOUNT_BOUND",
|
||||
primaryTransport: "SSE",
|
||||
endpointId: ENDPOINT_ID,
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("realtime policy registry", () => {
|
||||
limits: { maxQueueEvents: TEST_LIMITS.maxQueueEvents },
|
||||
});
|
||||
expect(registry.findEventType(EVENT_TYPE)?.owner).toBe(
|
||||
"reference-feature",
|
||||
"sample-owner",
|
||||
);
|
||||
expect(
|
||||
registry.findStreamEventType(STREAM_ID, EVENT_TYPE)?.id,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseBuildManifestArtifact,
|
||||
parseReleaseArtifact,
|
||||
parseRuntimeConfigArtifact,
|
||||
projectReleaseTokens,
|
||||
} from "../../scripts/contracts/release-artifacts.ts";
|
||||
|
||||
const EMPTY_CONTRACT_SET_DIGEST =
|
||||
"sha256:ad6aab71fea6a9ff87cbd170b984b339965afc90d85bb57f87801c9e0c020da2";
|
||||
|
||||
const releaseV2 = {
|
||||
schemaVersion: 2,
|
||||
appVersion: "0.1.0",
|
||||
buildId: "build-a",
|
||||
commitSha: "abc1234",
|
||||
configSchemaVersion: "2.0",
|
||||
assetManifestHash: "asset-hash",
|
||||
releaseId: "release-a",
|
||||
builtAt: "2026-08-01T00:00:00.000Z",
|
||||
routeChunks: { "route-home": "assets/home.js" },
|
||||
contractSet: {
|
||||
setAlgorithm: "CA_CONTRACT_SET_V1",
|
||||
setDigest: EMPTY_CONTRACT_SET_DIGEST,
|
||||
packages: [],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const buildManifest = {
|
||||
schemaVersion: 1,
|
||||
buildId: "build-a",
|
||||
commitSha: "abc1234",
|
||||
releaseId: "release-a",
|
||||
moduleInventoryHash: "inventory-hash",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
buildContext: {
|
||||
nodeVersion: "v24.14.0",
|
||||
packageManagerVersion: "11.17.0",
|
||||
runnerImage: "linux-x64",
|
||||
sourceDateEpoch: null,
|
||||
},
|
||||
outputs: {
|
||||
directory: "dist",
|
||||
viteManifest: "dist/.vite/manifest.json",
|
||||
moduleInventory: "artifacts/quality/vite-module-inventory.json",
|
||||
routeChunks: { "route-home": "assets/home.js" },
|
||||
runtimeConfigSchema: "dist/runtime-config.schema.json",
|
||||
},
|
||||
} as const;
|
||||
|
||||
describe("release artifact contracts", () => {
|
||||
it("projects the nested V2 contract-set digest without a legacy scalar", () => {
|
||||
const release = parseReleaseArtifact(releaseV2);
|
||||
|
||||
expect(projectReleaseTokens(release)).toMatchObject({
|
||||
schemaVersion: 2,
|
||||
buildId: "build-a",
|
||||
contractSetDigest: EMPTY_CONTRACT_SET_DIGEST,
|
||||
});
|
||||
expect(projectReleaseTokens(release)).not.toHaveProperty(
|
||||
"apiContractVersion",
|
||||
);
|
||||
});
|
||||
|
||||
it("projects the legacy scalar only for V1", () => {
|
||||
const { contractSet: _contractSet, ...releaseWithoutContractSet } = releaseV2;
|
||||
void _contractSet;
|
||||
const release = parseReleaseArtifact({
|
||||
...releaseWithoutContractSet,
|
||||
schemaVersion: 1,
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1.4.0",
|
||||
});
|
||||
|
||||
expect(projectReleaseTokens(release)).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
apiContractVersion: "1.4.0",
|
||||
});
|
||||
expect(projectReleaseTokens(release)).not.toHaveProperty(
|
||||
"contractSetDigest",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a V2 release carrying the removed scalar", () => {
|
||||
expect(() =>
|
||||
parseReleaseArtifact({
|
||||
...releaseV2,
|
||||
apiContractVersion: "1",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts the exact build manifest emitted by the generator", () => {
|
||||
expect(parseBuildManifestArtifact(buildManifest)).toEqual(buildManifest);
|
||||
});
|
||||
|
||||
it("rejects unknown build manifest output fields", () => {
|
||||
expect(() =>
|
||||
parseBuildManifestArtifact({
|
||||
...buildManifest,
|
||||
outputs: { ...buildManifest.outputs, unexpected: "value" },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts Runtime Config V2 without a legacy API scalar", () => {
|
||||
expect(
|
||||
parseRuntimeConfigArtifact({
|
||||
APP_ENV: "local",
|
||||
API_BASE_URL: "http://localhost:8080/",
|
||||
REQUEST_TIMEOUT_MS: 10_000,
|
||||
MAX_RETRY_ATTEMPTS: 2,
|
||||
TELEMETRY_ENABLED: false,
|
||||
AUTH_MODE: "demo",
|
||||
CONFIG_SCHEMA_VERSION: "2.0",
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
BUILD_ID: "build-a",
|
||||
RELEASE_ID: "release-a",
|
||||
CAPABILITY_OVERRIDES: {
|
||||
REALTIME: "DEFAULT",
|
||||
WEB_WORKER: "DEFAULT",
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
}),
|
||||
).not.toHaveProperty("API_CONTRACT_VERSION");
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,12 @@ import {
|
||||
} from "../../src/contracts/release-tokens.ts";
|
||||
|
||||
describe("release coherence", () => {
|
||||
it("owns all eight release tokens and keeps builtAt diagnostic-only", () => {
|
||||
expect(Object.keys(RELEASE_TOKEN_REGISTRY)).toHaveLength(8);
|
||||
it("owns all nine release tokens and keeps builtAt diagnostic-only", () => {
|
||||
// §5.2 adds contractSetDigest beside the legacy apiContractVersion scalar.
|
||||
expect(Object.keys(RELEASE_TOKEN_REGISTRY)).toHaveLength(9);
|
||||
expect(RELEASE_TOKEN_REGISTRY.contractSetDigest.compatibilityRole).toContain(
|
||||
"multi-package",
|
||||
);
|
||||
expect(RELEASE_TOKEN_REGISTRY.builtAt.compatibilityRole).toContain(
|
||||
"never cache identity",
|
||||
);
|
||||
|
||||
@@ -1,11 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { defineRestOperation } from "../../src/contracts/api-operations.ts";
|
||||
import {
|
||||
createRestProviderProfile,
|
||||
resolveRestSecurityProfiles,
|
||||
validateRestProfileBindings,
|
||||
} from "../../src/contracts/rest-profiles.ts";
|
||||
import { REFERENCE_FEATURE_CONTRACT } from "../../src/features/reference-feature/contracts/reference-feature-contract.ts";
|
||||
|
||||
/**
|
||||
* §24.12: the common REST profile contract must stay independently verifiable
|
||||
* after the sample feature is removed, so this suite owns its own operation
|
||||
* fixture instead of importing an installed feature contract.
|
||||
*/
|
||||
const SAMPLE_COMMAND = defineRestOperation({
|
||||
method: "POST",
|
||||
path: "/api/sample-resources",
|
||||
operationId: "CREATE_SAMPLE_RESOURCE",
|
||||
auth: "external-session",
|
||||
timeoutMs: null,
|
||||
idempotency: "keyed",
|
||||
retry: "runtime",
|
||||
requestSource: "body",
|
||||
requestSchema: "SampleCommand",
|
||||
responseSchema: "SamplePayload",
|
||||
owner: "platform-test-fixture",
|
||||
contractVersion: 2,
|
||||
protocol: "REST",
|
||||
semantics: "COMMAND",
|
||||
replayPolicy: "KEYED_COMMAND",
|
||||
idempotencyKeyPolicy: "REQUIRED",
|
||||
mapperId: "SampleMapper",
|
||||
successStatuses: [200, 201],
|
||||
responseMediaTypes: ["application/json"],
|
||||
maxResponseBytes: 32_768,
|
||||
providerId: "PRIMARY_API",
|
||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||
csrfProfileId: "NO_CSRF_BEARER",
|
||||
pathSchema: "NoRequest",
|
||||
pathParameterNames: [],
|
||||
maxEncodedSearchBytes: 0,
|
||||
});
|
||||
|
||||
const SAMPLE_OPERATIONS = Object.freeze({
|
||||
CREATE_SAMPLE_RESOURCE: SAMPLE_COMMAND,
|
||||
});
|
||||
|
||||
describe("REST provider/auth/CSRF profiles", () => {
|
||||
it("preserves the provider prefix and rejects unsafe endpoint forms", () => {
|
||||
@@ -33,10 +71,8 @@ describe("REST provider/auth/CSRF profiles", () => {
|
||||
});
|
||||
|
||||
it("resolves bearer auth to omit credentials and no CSRF", () => {
|
||||
const operation =
|
||||
REFERENCE_FEATURE_CONTRACT.apiOperations.CREATE_REFERENCE_RESOURCE;
|
||||
const resolved = resolveRestSecurityProfiles(
|
||||
operation,
|
||||
SAMPLE_COMMAND,
|
||||
createRestProviderProfile("PRIMARY_API", "https://api.test", ["omit"]),
|
||||
);
|
||||
expect(resolved).toMatchObject({
|
||||
@@ -49,18 +85,14 @@ describe("REST provider/auth/CSRF profiles", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("validates every installed reference profile binding as a set", () => {
|
||||
it("validates every installed profile binding as a set", () => {
|
||||
expect(
|
||||
validateRestProfileBindings(
|
||||
REFERENCE_FEATURE_CONTRACT.apiOperations,
|
||||
{ PRIMARY_API: ["omit"] },
|
||||
),
|
||||
validateRestProfileBindings(SAMPLE_OPERATIONS, {
|
||||
PRIMARY_API: ["omit"],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(() =>
|
||||
validateRestProfileBindings(
|
||||
REFERENCE_FEATURE_CONTRACT.apiOperations,
|
||||
{},
|
||||
),
|
||||
validateRestProfileBindings(SAMPLE_OPERATIONS, {}),
|
||||
).toThrow("Unregistered REST provider binding");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
createRuntimeAdapters,
|
||||
createRuntimeHttpClient,
|
||||
} from "../../src/bootstrap/runtime-adapters.ts";
|
||||
import { QUERY_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
import { REFERENCE_FEATURE_ID } from "../../src/features/reference-feature/contracts/reference-feature-contract.ts";
|
||||
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.ts";
|
||||
|
||||
type Runtime = Parameters<typeof createRuntimeAdapters>[0]["runtime"];
|
||||
@@ -18,9 +20,15 @@ const runtime: Runtime = {
|
||||
REQUEST_TIMEOUT_MS: 4321,
|
||||
MAX_RETRY_ATTEMPTS: 0,
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
CONFIG_SCHEMA_VERSION: "1",
|
||||
API_CONTRACT_VERSION: "1",
|
||||
CONFIG_SCHEMA_VERSION: "2.0",
|
||||
CAPABILITY_OVERRIDES: {
|
||||
REALTIME: "DEFAULT",
|
||||
WEB_WORKER: "DEFAULT",
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
},
|
||||
configSchema: "V2",
|
||||
build: {
|
||||
buildId: "build-a",
|
||||
commitSha: "abc123",
|
||||
@@ -30,12 +38,16 @@ const runtime: Runtime = {
|
||||
validationDurationMs: 0,
|
||||
};
|
||||
const release: Release = {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
appVersion: "0.1.0",
|
||||
buildId: "build-a",
|
||||
commitSha: "abc123",
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
configSchemaVersion: "2.0",
|
||||
contractSet: {
|
||||
setAlgorithm: "CA_CONTRACT_SET_V1",
|
||||
setDigest: `sha256:${"0".repeat(64)}`,
|
||||
packages: [],
|
||||
},
|
||||
assetManifestHash: "hash-a",
|
||||
releaseId: "release-a",
|
||||
builtAt: "2026-07-25T00:00:00Z",
|
||||
@@ -67,6 +79,67 @@ describe("runtime adapter composition", () => {
|
||||
adapters.infrastructure.dispose();
|
||||
});
|
||||
|
||||
it("executes installed feature HTTP through the composed contract registry", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json([{ id: "reference-1", name: "Direct contract payload" }]),
|
||||
);
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime,
|
||||
release,
|
||||
host: {},
|
||||
fetcher,
|
||||
});
|
||||
await adapters.outputPorts.session.beginSignIn();
|
||||
await vi.waitFor(() =>
|
||||
expect(adapters.infrastructure.serverStateScope.getPhase()).toBe("READY"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
adapters.featureInputs[REFERENCE_FEATURE_ID].listResources({ limit: 20 }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
resourceId: "reference-1",
|
||||
title: "Direct contract payload",
|
||||
createdAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
"http://localhost:8080/api/reference-resources?limit=20",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
}),
|
||||
);
|
||||
adapters.infrastructure.dispose();
|
||||
});
|
||||
|
||||
it("replaces the QueryClient and coordinator for each session generation", async () => {
|
||||
const adapters = await createRuntimeAdapters({ runtime, release, host: {} });
|
||||
const previousClient = adapters.infrastructure.queryClient;
|
||||
const previousCoordinator = adapters.infrastructure.queryInvalidation;
|
||||
const invalidatePrevious = vi.spyOn(previousClient, "invalidateQueries");
|
||||
|
||||
await adapters.outputPorts.session.beginSignIn();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(adapters.infrastructure.queryClient).not.toBe(previousClient),
|
||||
);
|
||||
expect(adapters.infrastructure.queryInvalidation).not.toBe(
|
||||
previousCoordinator,
|
||||
);
|
||||
|
||||
const topic = Object.values(QUERY_REGISTRY)[0]?.invalidationTopic;
|
||||
if (!topic) throw new Error("expected an installed invalidation topic");
|
||||
await previousCoordinator.invalidate([topic]);
|
||||
expect(invalidatePrevious).not.toHaveBeenCalled();
|
||||
|
||||
adapters.infrastructure.dispose();
|
||||
});
|
||||
|
||||
it("does not fail boot when Web Storage capability getters throw", async () => {
|
||||
const host: Record<string, unknown> = {};
|
||||
Object.defineProperties(host, {
|
||||
@@ -108,6 +181,73 @@ describe("runtime adapter composition", () => {
|
||||
expect(adapters.outputPorts.session.getState()).toBe("integration-failed");
|
||||
});
|
||||
|
||||
it("reports the static selection when no capability override disables it", async () => {
|
||||
const adapters = await createRuntimeAdapters({ runtime, release, host: {} });
|
||||
|
||||
const snapshot = adapters.outputPorts.runtimeCapabilities.getSnapshot();
|
||||
|
||||
expect(snapshot.map((status) => status.capabilityId)).toEqual([
|
||||
"REALTIME",
|
||||
"WEB_WORKER",
|
||||
"SERVICE_WORKER",
|
||||
"OFFLINE_COMMANDS",
|
||||
]);
|
||||
expect(snapshot.every((status) => status.override === "DEFAULT")).toBe(true);
|
||||
});
|
||||
|
||||
it("carries a disabling override into the capability snapshot", async () => {
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime: {
|
||||
...runtime,
|
||||
config: {
|
||||
...runtime.config,
|
||||
CAPABILITY_OVERRIDES: {
|
||||
...runtime.config.CAPABILITY_OVERRIDES,
|
||||
SERVICE_WORKER: "DISABLED",
|
||||
},
|
||||
},
|
||||
},
|
||||
release,
|
||||
host: {},
|
||||
});
|
||||
|
||||
const serviceWorker = adapters.outputPorts.runtimeCapabilities
|
||||
.getSnapshot()
|
||||
.find((status) => status.capabilityId === "SERVICE_WORKER");
|
||||
|
||||
expect(serviceWorker?.override).toBe("DISABLED");
|
||||
expect(serviceWorker?.active).toBe(0);
|
||||
});
|
||||
|
||||
it("states contract identity as a digest for a V2 release manifest", async () => {
|
||||
const adapters = await createRuntimeAdapters({ runtime, release, host: {} });
|
||||
|
||||
const current = await adapters.outputPorts.releaseInfo.getCurrent();
|
||||
|
||||
expect(current.contractSetDigest).toBe(release.contractSet?.setDigest);
|
||||
expect(current.apiContractVersion).toBeUndefined();
|
||||
expect(current).not.toHaveProperty("contractSet");
|
||||
});
|
||||
|
||||
it("states contract identity as the legacy scalar for a V1 release manifest", async () => {
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime,
|
||||
release: {
|
||||
...release,
|
||||
schemaVersion: 1,
|
||||
contractSet: null,
|
||||
legacyApiContractVersion: "1.4",
|
||||
},
|
||||
host: {},
|
||||
});
|
||||
|
||||
const current = await adapters.outputPorts.releaseInfo.getCurrent();
|
||||
|
||||
expect(current.apiContractVersion).toBe("1.4");
|
||||
expect(current.contractSetDigest).toBeUndefined();
|
||||
expect(current).not.toHaveProperty("legacyApiContractVersion");
|
||||
});
|
||||
|
||||
it("refetches the active release manifest with no-store semantics", async () => {
|
||||
const activeRelease = {
|
||||
...release,
|
||||
@@ -115,7 +255,11 @@ describe("runtime adapter composition", () => {
|
||||
releaseId: "release-b",
|
||||
routeChunks: { "route-home": "assets/home-b.js" },
|
||||
};
|
||||
const fetcher = vi.fn(async () => Response.json(activeRelease));
|
||||
const fetcher = vi.fn(async () =>
|
||||
new Response(JSON.stringify(activeRelease), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime,
|
||||
release,
|
||||
@@ -127,10 +271,17 @@ describe("runtime adapter composition", () => {
|
||||
buildId: "build-b",
|
||||
releaseId: "release-b",
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledWith("/release-manifest.json", {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
"/release-manifest.json",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
headers: { Accept: "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("injects runtime timeout and max-attempt policy into HTTP execution", async () => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
describeRuntimeCapabilities,
|
||||
type CapabilityOverrideMap,
|
||||
type InstalledRuntimeCapabilities,
|
||||
} from "../../src/contracts/runtime-capabilities.ts";
|
||||
import { SERVICE_WORKER_SCRIPT_PATH } from "../../src/contracts/service-worker.ts";
|
||||
|
||||
const DEFAULTS: CapabilityOverrideMap = Object.freeze({
|
||||
REALTIME: "DEFAULT",
|
||||
WEB_WORKER: "DEFAULT",
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
});
|
||||
|
||||
const EMPTY: InstalledRuntimeCapabilities = Object.freeze({
|
||||
realtime: Object.freeze([]),
|
||||
webWorkers: Object.freeze([]),
|
||||
serviceWorker: null,
|
||||
offlineCommands: null,
|
||||
});
|
||||
|
||||
const SELECTED: InstalledRuntimeCapabilities = Object.freeze({
|
||||
realtime: Object.freeze([]),
|
||||
webWorkers: Object.freeze([]),
|
||||
serviceWorker: Object.freeze({
|
||||
mode: "ACTIVE" as const,
|
||||
scriptPath: SERVICE_WORKER_SCRIPT_PATH,
|
||||
handlers: Object.freeze(["PWA_STATIC_ASSETS" as const]),
|
||||
}),
|
||||
offlineCommands: null,
|
||||
});
|
||||
|
||||
describe("runtime capability snapshot", () => {
|
||||
it("describes every capability id in a fixed order", () => {
|
||||
const snapshot = describeRuntimeCapabilities(EMPTY, DEFAULTS);
|
||||
|
||||
expect(snapshot.map((status) => status.capabilityId)).toEqual([
|
||||
"REALTIME",
|
||||
"WEB_WORKER",
|
||||
"SERVICE_WORKER",
|
||||
"OFFLINE_COMMANDS",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports nothing selected and nothing active for an empty selection", () => {
|
||||
const snapshot = describeRuntimeCapabilities(EMPTY, DEFAULTS);
|
||||
|
||||
for (const status of snapshot) {
|
||||
expect(status.selected).toBe(0);
|
||||
expect(status.active).toBe(0);
|
||||
expect(status.override).toBe("DEFAULT");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a selected capability active while the override is DEFAULT", () => {
|
||||
const snapshot = describeRuntimeCapabilities(SELECTED, DEFAULTS);
|
||||
const serviceWorker = snapshot.find(
|
||||
(status) => status.capabilityId === "SERVICE_WORKER",
|
||||
);
|
||||
|
||||
expect(serviceWorker).toMatchObject({
|
||||
selected: 1,
|
||||
active: 1,
|
||||
override: "DEFAULT",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a disabled capability selected but not active", () => {
|
||||
const snapshot = describeRuntimeCapabilities(SELECTED, {
|
||||
...DEFAULTS,
|
||||
SERVICE_WORKER: "DISABLED",
|
||||
});
|
||||
const serviceWorker = snapshot.find(
|
||||
(status) => status.capabilityId === "SERVICE_WORKER",
|
||||
);
|
||||
|
||||
expect(serviceWorker).toMatchObject({
|
||||
selected: 1,
|
||||
active: 0,
|
||||
override: "DISABLED",
|
||||
});
|
||||
});
|
||||
|
||||
it("cannot activate a capability that was never selected", () => {
|
||||
const snapshot = describeRuntimeCapabilities(EMPTY, {
|
||||
...DEFAULTS,
|
||||
REALTIME: "DEFAULT",
|
||||
});
|
||||
const realtime = snapshot.find(
|
||||
(status) => status.capabilityId === "REALTIME",
|
||||
);
|
||||
|
||||
expect(realtime).toMatchObject({ selected: 0, active: 0 });
|
||||
});
|
||||
|
||||
it("returns a frozen snapshot and frozen entries", () => {
|
||||
const snapshot = describeRuntimeCapabilities(EMPTY, DEFAULTS);
|
||||
|
||||
expect(Object.isFrozen(snapshot)).toBe(true);
|
||||
expect(snapshot.every((status) => Object.isFrozen(status))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,17 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createServerStateScopeRuntime } from "../../src/adapters/query-cache/server-state-scope-runtime.ts";
|
||||
import type { ClientScopeLifecycleEvent } from "../../src/contracts/server-state-scope.ts";
|
||||
|
||||
describe("server-state session generation runtime", () => {
|
||||
it("fences the old generation before reset and publishes the new scope after reset", async () => {
|
||||
it("fences the old generation synchronously and publishes READY after the reset order", async () => {
|
||||
let sessionListener: () => void = () => {};
|
||||
let completeReset: () => void = () => {};
|
||||
const reset = new Promise<void>((resolve) => {
|
||||
completeReset = resolve;
|
||||
});
|
||||
const resetLocal = vi.fn(() => reset);
|
||||
const participantOrder: string[] = [];
|
||||
let tokenSequence = 0;
|
||||
const runtime = createServerStateScopeRuntime({
|
||||
session: {
|
||||
@@ -19,34 +21,177 @@ describe("server-state session generation runtime", () => {
|
||||
},
|
||||
},
|
||||
queryInvalidation: {
|
||||
invalidate: async () => {},
|
||||
beginMutation: () => ({ release: async () => {} }),
|
||||
resetLocal,
|
||||
dispose() {},
|
||||
},
|
||||
tokenFactory: () => `scope-token-${String(tokenSequence++).padStart(8, "0")}`,
|
||||
participants: [
|
||||
{
|
||||
order: 9,
|
||||
label: "realtime",
|
||||
close: () => void participantOrder.push("realtime"),
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
label: "admission",
|
||||
close: () => void participantOrder.push("admission"),
|
||||
},
|
||||
],
|
||||
tokenFactory: () =>
|
||||
`scope-token-${String(tokenSequence++).padStart(8, "0")}`,
|
||||
});
|
||||
const changed = vi.fn();
|
||||
const lifecycle: ClientScopeLifecycleEvent[] = [];
|
||||
runtime.subscribe(changed);
|
||||
runtime.subscribeLifecycle((event) => lifecycle.push(event));
|
||||
const before = runtime.getSnapshot();
|
||||
const identity = before.identities.intern({ id: "private" });
|
||||
identity.acquire();
|
||||
|
||||
sessionListener();
|
||||
|
||||
// §10.6 steps 1-3 are synchronous: the old snapshot is immediately stale,
|
||||
// FENCED is published, and subscribers are notified before any await.
|
||||
expect(before.isCurrent()).toBe(false);
|
||||
expect(runtime.getSnapshot()).toBe(before);
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
expect(before.signal.aborted).toBe(true);
|
||||
expect(runtime.getPhase()).toBe("FENCED");
|
||||
expect(lifecycle[0]).toEqual({ kind: "FENCED", previousGeneration: 1 });
|
||||
expect(changed).toHaveBeenCalledOnce();
|
||||
// Nothing may read as current while the scope is fenced.
|
||||
expect(runtime.getSnapshot().isCurrent()).toBe(false);
|
||||
|
||||
await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce());
|
||||
// Participants close in §10.6 step order, before the local cache reset.
|
||||
expect(participantOrder).toEqual(["admission", "realtime"]);
|
||||
|
||||
completeReset();
|
||||
await vi.waitFor(() => expect(changed).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() => expect(runtime.getPhase()).toBe("READY"));
|
||||
const after = runtime.getSnapshot();
|
||||
expect(after.generation).toBe(before.generation + 1);
|
||||
expect(after.fingerprint).not.toBe(before.fingerprint);
|
||||
expect(after.isCurrent()).toBe(true);
|
||||
expect(after.signal.aborted).toBe(false);
|
||||
expect(after.signal).not.toBe(before.signal);
|
||||
expect(before.identities.inspect().closed).toBe(true);
|
||||
expect(lifecycle.at(-1)).toMatchObject({ kind: "READY" });
|
||||
expect(changed).toHaveBeenCalledTimes(2);
|
||||
|
||||
runtime.dispose();
|
||||
expect(runtime.getPhase()).toBe("DISPOSED");
|
||||
expect(after.identities.inspect().closed).toBe(true);
|
||||
expect(after.isCurrent()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a throwing snapshot subscriber prevent reset", async () => {
|
||||
let sessionListener: () => void = () => {};
|
||||
const resetLocal = vi.fn(async () => {});
|
||||
const runtime = createServerStateScopeRuntime({
|
||||
session: {
|
||||
subscribe(listener) {
|
||||
sessionListener = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
queryInvalidation: {
|
||||
resetLocal,
|
||||
},
|
||||
tokenFactory: () => "scope-listener-token-0001",
|
||||
});
|
||||
runtime.subscribe(() => {
|
||||
throw new Error("subscriber defect");
|
||||
});
|
||||
|
||||
expect(() => sessionListener()).not.toThrow();
|
||||
await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() => expect(runtime.getPhase()).toBe("READY"));
|
||||
});
|
||||
|
||||
it("remains failed when a mandatory participant cannot close", async () => {
|
||||
let sessionListener: () => void = () => {};
|
||||
const resetLocal = vi.fn(async () => {});
|
||||
const runtime = createServerStateScopeRuntime({
|
||||
session: {
|
||||
subscribe(listener) {
|
||||
sessionListener = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
queryInvalidation: {
|
||||
resetLocal,
|
||||
},
|
||||
participants: [
|
||||
{
|
||||
order: 4,
|
||||
label: "mandatory-admission",
|
||||
close: async () => {
|
||||
throw new Error("close failed");
|
||||
},
|
||||
},
|
||||
],
|
||||
tokenFactory: () => "scope-participant-token-0001",
|
||||
});
|
||||
|
||||
sessionListener();
|
||||
|
||||
await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.getPhase() as string).toBe("FAILED"),
|
||||
);
|
||||
expect(runtime.getSnapshot().isCurrent()).toBe(false);
|
||||
});
|
||||
|
||||
it("remains failed when local cache reset rejects", async () => {
|
||||
let sessionListener: () => void = () => {};
|
||||
const runtime = createServerStateScopeRuntime({
|
||||
session: {
|
||||
subscribe(listener) {
|
||||
sessionListener = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
queryInvalidation: {
|
||||
resetLocal: async () => {
|
||||
throw new Error("reset failed");
|
||||
},
|
||||
},
|
||||
tokenFactory: () => "scope-reset-token-0000001",
|
||||
});
|
||||
|
||||
sessionListener();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.getPhase() as string).toBe("FAILED"),
|
||||
);
|
||||
expect(runtime.getSnapshot().isCurrent()).toBe(false);
|
||||
});
|
||||
|
||||
it("activates the next generation after reset and fails closed on activation error", async () => {
|
||||
let sessionListener: () => void = () => {};
|
||||
const steps: string[] = [];
|
||||
const runtime = createServerStateScopeRuntime({
|
||||
session: {
|
||||
subscribe(listener) {
|
||||
sessionListener = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
queryInvalidation: {
|
||||
resetLocal: async () => {
|
||||
steps.push("reset");
|
||||
},
|
||||
},
|
||||
activateNextGeneration: async () => {
|
||||
steps.push("activate");
|
||||
throw new Error("activation failed");
|
||||
},
|
||||
tokenFactory: () => "scope-activation-token-001",
|
||||
} as Parameters<typeof createServerStateScopeRuntime>[0] & {
|
||||
activateNextGeneration(): Promise<void>;
|
||||
});
|
||||
|
||||
sessionListener();
|
||||
|
||||
await vi.waitFor(() => expect(steps).toEqual(["reset", "activate"]));
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.getPhase() as string).toBe("FAILED"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveServiceWorkerBuildInput } from "../../scripts/lib/service-worker-build-input.ts";
|
||||
|
||||
const digest = (character: string) => `sha256:${character.repeat(64)}`;
|
||||
|
||||
describe("service worker build input", () => {
|
||||
const selection = {
|
||||
mode: "ACTIVE" as const,
|
||||
scriptPath: "service-worker.js" as const,
|
||||
handlers: ["PWA_STATIC_ASSETS" as const],
|
||||
};
|
||||
const assets = {
|
||||
schemaVersion: 1 as const,
|
||||
buildId: "build-1",
|
||||
releaseId: "release-1",
|
||||
setDigest: digest("a"),
|
||||
assets: [],
|
||||
};
|
||||
|
||||
it("rejects a direct worker build when ACTIVE selection or generated inputs are absent", () => {
|
||||
expect(() =>
|
||||
resolveServiceWorkerBuildInput({
|
||||
selection: null,
|
||||
assets,
|
||||
contractSet: { setDigest: digest("b") },
|
||||
runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" },
|
||||
buildId: "build-1",
|
||||
releaseId: "release-1",
|
||||
}),
|
||||
).toThrow(/ACTIVE/u);
|
||||
expect(() =>
|
||||
resolveServiceWorkerBuildInput({
|
||||
selection,
|
||||
assets: null,
|
||||
contractSet: { setDigest: digest("b") },
|
||||
runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" },
|
||||
buildId: "build-1",
|
||||
releaseId: "release-1",
|
||||
}),
|
||||
).toThrow(/asset manifest/u);
|
||||
});
|
||||
|
||||
it("rejects stale generated identity instead of compiling a mismatched worker", () => {
|
||||
expect(() =>
|
||||
resolveServiceWorkerBuildInput({
|
||||
selection,
|
||||
assets: { ...assets, buildId: "old-build" },
|
||||
contractSet: { setDigest: digest("b") },
|
||||
runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" },
|
||||
buildId: "build-1",
|
||||
releaseId: "release-1",
|
||||
}),
|
||||
).toThrow(/identity/u);
|
||||
});
|
||||
|
||||
it("rejects WEB_PUSH selection until its product-owned worker contribution exists", () => {
|
||||
expect(() =>
|
||||
resolveServiceWorkerBuildInput({
|
||||
selection: { ...selection, handlers: ["WEB_PUSH"] },
|
||||
assets,
|
||||
contractSet: { setDigest: digest("b") },
|
||||
runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" },
|
||||
buildId: "build-1",
|
||||
releaseId: "release-1",
|
||||
}),
|
||||
).toThrow(/WEB_PUSH.*contribution/u);
|
||||
});
|
||||
|
||||
it("returns only fully matched, digest-bearing generated inputs", () => {
|
||||
expect(
|
||||
resolveServiceWorkerBuildInput({
|
||||
selection,
|
||||
assets,
|
||||
contractSet: { setDigest: digest("b") },
|
||||
runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" },
|
||||
buildId: "build-1",
|
||||
releaseId: "release-1",
|
||||
}),
|
||||
).toMatchObject({
|
||||
assets,
|
||||
handlers: ["PWA_STATIC_ASSETS"],
|
||||
contractSetDigest: digest("b"),
|
||||
releaseManifestUrl: "/release-manifest.json",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createServiceWorkerPageController } from "../../src/adapters/service-worker/service-worker-page-controller.ts";
|
||||
import { createServiceWorkerRuntime } from "../../src/adapters/service-worker/service-worker-lifecycle.ts";
|
||||
import { createServiceWorkerMessage } from "../../src/adapters/service-worker/service-worker-protocol.ts";
|
||||
import { installStaticAssets } from "../../src/adapters/service-worker/service-worker-static-assets.ts";
|
||||
import {
|
||||
SERVICE_WORKER_BOUNDS,
|
||||
SERVICE_WORKER_SCRIPT_PATH,
|
||||
STATIC_CACHE_PREFIX,
|
||||
staticCacheName,
|
||||
type ServiceWorkerProtocolIdentity,
|
||||
type StaticAssetManifestV1,
|
||||
} from "../../src/contracts/service-worker.ts";
|
||||
|
||||
const ORIGIN = "https://app.example";
|
||||
const SCRIPT_URL = `${ORIGIN}/service-worker.js`;
|
||||
|
||||
function pageContainer(options: { waiting?: boolean; controlled?: boolean } = {}) {
|
||||
const listeners = new Set<(event: MessageEvent) => void>();
|
||||
const waitingMessages: unknown[] = [];
|
||||
const controllerMessages: unknown[] = [];
|
||||
const worker = (messages: unknown[]) =>
|
||||
({
|
||||
scriptURL: SCRIPT_URL,
|
||||
postMessage(message: unknown) {
|
||||
messages.push(message);
|
||||
},
|
||||
}) as unknown as ServiceWorker;
|
||||
const waiting = options.waiting ? worker(waitingMessages) : null;
|
||||
const active = options.waiting ? null : worker([]);
|
||||
const controlled = options.controlled ? worker(controllerMessages) : null;
|
||||
const registration = {
|
||||
scope: `${ORIGIN}/`,
|
||||
installing: null,
|
||||
waiting,
|
||||
active,
|
||||
update: vi.fn(async () => {}),
|
||||
} as unknown as ServiceWorkerRegistration;
|
||||
const container = {
|
||||
controller: controlled,
|
||||
register: vi.fn(async () => registration),
|
||||
addEventListener(_type: string, listener: (event: MessageEvent) => void) {
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeEventListener(_type: string, listener: (event: MessageEvent) => void) {
|
||||
listeners.delete(listener);
|
||||
},
|
||||
} as unknown as ServiceWorkerContainer;
|
||||
return {
|
||||
container,
|
||||
registration,
|
||||
waitingMessages,
|
||||
controllerMessages,
|
||||
listenerCount: () => listeners.size,
|
||||
dispatch(data: unknown, source?: { postMessage(message: unknown): void }) {
|
||||
const event = { data, origin: ORIGIN, source } as unknown as MessageEvent;
|
||||
for (const listener of [...listeners]) listener(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pageController(container: ServiceWorkerContainer, blockers = [() => false]) {
|
||||
return createServiceWorkerPageController({
|
||||
selection: {
|
||||
mode: "ACTIVE",
|
||||
scriptPath: SERVICE_WORKER_SCRIPT_PATH,
|
||||
handlers: [],
|
||||
},
|
||||
disabledCleanup: false,
|
||||
routerBasePath: "/",
|
||||
origin: ORIGIN,
|
||||
buildId: "page-build",
|
||||
container,
|
||||
blockers,
|
||||
});
|
||||
}
|
||||
|
||||
const identity: ServiceWorkerProtocolIdentity = {
|
||||
serviceWorkerProtocolVersion: 1,
|
||||
cacheSchemaVersion: 1,
|
||||
buildId: "worker-build",
|
||||
releaseId: "release-1",
|
||||
contractSetDigest: `sha256:${"1".repeat(64)}`,
|
||||
staticAssetSetDigest: `sha256:${"2".repeat(64)}`,
|
||||
};
|
||||
|
||||
function workerScope() {
|
||||
const deleted: string[] = [];
|
||||
const clients = ["client-a", "client-b"].map((id) => ({
|
||||
id,
|
||||
url: `${ORIGIN}/app/${id}`,
|
||||
messages: [] as unknown[],
|
||||
postMessage(message: unknown) {
|
||||
this.messages.push(message);
|
||||
},
|
||||
}));
|
||||
const scope = {
|
||||
caches: {
|
||||
open: vi.fn(),
|
||||
keys: vi.fn(async () => [
|
||||
`${STATIC_CACHE_PREFIX}${"a".repeat(16)}`,
|
||||
`${STATIC_CACHE_PREFIX}${"b".repeat(16)}`,
|
||||
"foreign-cache",
|
||||
]),
|
||||
delete: vi.fn(async (name: string) => {
|
||||
deleted.push(name);
|
||||
return true;
|
||||
}),
|
||||
match: vi.fn(),
|
||||
},
|
||||
clients: { matchAll: vi.fn(async () => clients) },
|
||||
registrationScope: `${ORIGIN}/app/`,
|
||||
skipWaiting: vi.fn(async () => {}),
|
||||
fetcher: vi.fn(),
|
||||
digest: vi.fn(),
|
||||
};
|
||||
return { scope, clients, deleted };
|
||||
}
|
||||
|
||||
describe("service worker page protocol", () => {
|
||||
it("does not attach late listeners when stopped during registration", async () => {
|
||||
const browser = pageContainer();
|
||||
let completeRegistration: ((value: ServiceWorkerRegistration) => void) | undefined;
|
||||
const deferredRegistration = new Promise<ServiceWorkerRegistration>((resolve) => {
|
||||
completeRegistration = resolve;
|
||||
});
|
||||
vi.mocked(browser.container.register).mockReturnValue(deferredRegistration);
|
||||
const controller = pageController(browser.container);
|
||||
|
||||
const starting = controller.start();
|
||||
await Promise.resolve();
|
||||
await controller.stop();
|
||||
completeRegistration?.(browser.registration);
|
||||
|
||||
await expect(starting).resolves.toEqual({ kind: "FAILED", code: "STOPPED" });
|
||||
expect(browser.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("answers a worker drain request only after local blockers are clear", async () => {
|
||||
const browser = pageContainer({ waiting: true });
|
||||
const controller = pageController(browser.container);
|
||||
await controller.start();
|
||||
const source = { postMessage: vi.fn() };
|
||||
|
||||
browser.dispatch(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAIN_REQUEST",
|
||||
sourceBuildId: "worker-build",
|
||||
targetBuildId: "page-build",
|
||||
nonce: "drain-1",
|
||||
}),
|
||||
source,
|
||||
);
|
||||
|
||||
expect(source.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "CLIENT_DRAINED",
|
||||
sourceBuildId: "page-build",
|
||||
targetBuildId: "worker-build",
|
||||
nonce: "drain-1",
|
||||
}),
|
||||
);
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("settles a pending activation and removes its listener when stopped", async () => {
|
||||
const browser = pageContainer({ waiting: true });
|
||||
const controller = pageController(browser.container);
|
||||
await controller.start();
|
||||
|
||||
const activation = controller.requestActivation();
|
||||
await Promise.resolve();
|
||||
expect(browser.listenerCount()).toBe(2);
|
||||
|
||||
await controller.stop();
|
||||
|
||||
await expect(activation).resolves.toEqual({ kind: "FAILED", code: "STOPPED" });
|
||||
expect(browser.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("waits for the correlated cache reset result", async () => {
|
||||
const browser = pageContainer({ controlled: true });
|
||||
const controller = pageController(browser.container);
|
||||
await controller.start();
|
||||
|
||||
let settled = false;
|
||||
const result = controller.resetOwnedCaches().then((outcome) => {
|
||||
settled = true;
|
||||
return outcome;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
const request = browser.controllerMessages.at(-1) as { nonce?: string };
|
||||
browser.dispatch({
|
||||
...createServiceWorkerMessage({
|
||||
kind: "CACHE_RESET_RESULT",
|
||||
sourceBuildId: "worker-build",
|
||||
targetBuildId: "page-build",
|
||||
nonce: request.nonce,
|
||||
}),
|
||||
cachesDeleted: 2,
|
||||
});
|
||||
|
||||
await expect(result).resolves.toEqual({ kind: "RESET", cachesDeleted: 2 });
|
||||
await controller.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("service worker worker-side protocol", () => {
|
||||
it("retains the immediately previous verified static cache on activation", async () => {
|
||||
const current = staticCacheName(identity.staticAssetSetDigest);
|
||||
const stale = `${STATIC_CACHE_PREFIX}${"3".repeat(16)}`;
|
||||
const previous = `${STATIC_CACHE_PREFIX}${"4".repeat(16)}`;
|
||||
const names = [stale, previous, current, "foreign-cache"];
|
||||
const deleted: string[] = [];
|
||||
const markerPut = vi.fn(async () => {});
|
||||
const cache = {
|
||||
match: vi.fn(async () => undefined),
|
||||
put: markerPut,
|
||||
delete: vi.fn(async () => true),
|
||||
} as unknown as Cache;
|
||||
const scope = {
|
||||
caches: {
|
||||
open: vi.fn(async () => cache),
|
||||
keys: vi.fn(async () => names),
|
||||
delete: vi.fn(async (name: string) => {
|
||||
deleted.push(name);
|
||||
return true;
|
||||
}),
|
||||
match: vi.fn(),
|
||||
},
|
||||
clients: { matchAll: vi.fn(async () => []) },
|
||||
skipWaiting: vi.fn(async () => {}),
|
||||
fetcher: vi.fn(),
|
||||
digest: vi.fn(),
|
||||
};
|
||||
const runtime = createServiceWorkerRuntime(scope as never, {
|
||||
identity,
|
||||
handlers: ["PWA_STATIC_ASSETS"],
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
buildId: identity.buildId,
|
||||
releaseId: identity.releaseId,
|
||||
setDigest: identity.staticAssetSetDigest as `sha256:${string}`,
|
||||
assets: [],
|
||||
},
|
||||
runtimeConfigUrl: "/runtime-config.json",
|
||||
releaseManifestUrl: "/release-manifest.json",
|
||||
});
|
||||
|
||||
await expect(runtime.onActivate()).resolves.toBe(1);
|
||||
expect(deleted).toEqual([stale]);
|
||||
expect(markerPut).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("waits for every exact client drain acknowledgement before activation", async () => {
|
||||
const fixture = workerScope();
|
||||
const runtime = createServiceWorkerRuntime(fixture.scope as never, {
|
||||
identity,
|
||||
handlers: [],
|
||||
manifest: null,
|
||||
runtimeConfigUrl: "/runtime-config.json",
|
||||
releaseManifestUrl: "/release-manifest.json",
|
||||
});
|
||||
const request = createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_REQUEST",
|
||||
sourceBuildId: "page-build",
|
||||
targetBuildId: "worker-build",
|
||||
nonce: "activation-1",
|
||||
});
|
||||
|
||||
const activation = runtime.onActivateRequest(request);
|
||||
await Promise.resolve();
|
||||
expect(fixture.scope.skipWaiting).not.toHaveBeenCalled();
|
||||
|
||||
runtime.onClientMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAINED",
|
||||
sourceBuildId: "page-build",
|
||||
targetBuildId: "worker-build",
|
||||
nonce: "activation-1",
|
||||
}),
|
||||
"client-a",
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(fixture.scope.skipWaiting).not.toHaveBeenCalled();
|
||||
|
||||
runtime.onClientMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAINED",
|
||||
sourceBuildId: "page-build",
|
||||
targetBuildId: "worker-build",
|
||||
nonce: "activation-1",
|
||||
}),
|
||||
"client-b",
|
||||
);
|
||||
|
||||
await expect(activation).resolves.toBe("ACCEPTED");
|
||||
expect(fixture.scope.skipWaiting).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("enumerates uncontrolled window clients before an activation drain", async () => {
|
||||
const fixture = workerScope();
|
||||
const runtime = createServiceWorkerRuntime(fixture.scope as never, {
|
||||
identity,
|
||||
handlers: [],
|
||||
manifest: null,
|
||||
runtimeConfigUrl: "/runtime-config.json",
|
||||
releaseManifestUrl: "/release-manifest.json",
|
||||
});
|
||||
const request = createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_REQUEST",
|
||||
sourceBuildId: "page-build",
|
||||
targetBuildId: "worker-build",
|
||||
nonce: "activation-uncontrolled",
|
||||
});
|
||||
|
||||
const activation = runtime.onActivateRequest(request);
|
||||
await Promise.resolve();
|
||||
for (const client of fixture.clients) {
|
||||
runtime.onClientMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAINED",
|
||||
sourceBuildId: "page-build",
|
||||
targetBuildId: "worker-build",
|
||||
nonce: "activation-uncontrolled",
|
||||
}),
|
||||
client.id,
|
||||
);
|
||||
}
|
||||
|
||||
await expect(activation).resolves.toBe("ACCEPTED");
|
||||
expect(fixture.scope.clients.matchAll).toHaveBeenCalledWith({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("drains only page clients inside the exact registration scope", async () => {
|
||||
const inScope = {
|
||||
id: "client-in-scope",
|
||||
url: `${ORIGIN}/app/nested/page`,
|
||||
messages: [] as unknown[],
|
||||
postMessage(message: unknown) {
|
||||
this.messages.push(message);
|
||||
},
|
||||
};
|
||||
const outOfScope = {
|
||||
id: "client-out-of-scope",
|
||||
url: `${ORIGIN}/application/page`,
|
||||
messages: [] as unknown[],
|
||||
postMessage(message: unknown) {
|
||||
this.messages.push(message);
|
||||
},
|
||||
};
|
||||
const fixture = workerScope();
|
||||
fixture.scope.clients.matchAll.mockResolvedValue([
|
||||
inScope,
|
||||
outOfScope,
|
||||
]);
|
||||
const runtime = createServiceWorkerRuntime(fixture.scope as never, {
|
||||
identity,
|
||||
handlers: [],
|
||||
manifest: null,
|
||||
runtimeConfigUrl: "/runtime-config.json",
|
||||
releaseManifestUrl: "/release-manifest.json",
|
||||
});
|
||||
const activation = runtime.onActivateRequest(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_REQUEST",
|
||||
sourceBuildId: "page-build",
|
||||
targetBuildId: "worker-build",
|
||||
nonce: "activation-scope",
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
runtime.onClientMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAINED",
|
||||
sourceBuildId: "page-build",
|
||||
targetBuildId: "worker-build",
|
||||
nonce: "activation-scope",
|
||||
}),
|
||||
inScope.id,
|
||||
);
|
||||
runtime.onClientMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAINED",
|
||||
sourceBuildId: "page-build",
|
||||
targetBuildId: "worker-build",
|
||||
nonce: "activation-scope",
|
||||
}),
|
||||
outOfScope.id,
|
||||
);
|
||||
|
||||
await expect(activation).resolves.toBe("ACCEPTED");
|
||||
expect(inScope.messages).not.toHaveLength(0);
|
||||
expect(outOfScope.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("deletes only owned caches and returns the correlated reset count", async () => {
|
||||
const fixture = workerScope();
|
||||
const runtime = createServiceWorkerRuntime(fixture.scope as never, {
|
||||
identity,
|
||||
handlers: [],
|
||||
manifest: null,
|
||||
runtimeConfigUrl: "/runtime-config.json",
|
||||
releaseManifestUrl: "/release-manifest.json",
|
||||
});
|
||||
const source = {
|
||||
id: "client-a",
|
||||
url: `${ORIGIN}/app/client-a`,
|
||||
postMessage: vi.fn(),
|
||||
};
|
||||
|
||||
await runtime.onCacheResetRequest(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CACHE_RESET_REQUEST",
|
||||
sourceBuildId: "page-build",
|
||||
targetBuildId: "worker-build",
|
||||
nonce: "reset-1",
|
||||
}),
|
||||
source,
|
||||
);
|
||||
|
||||
expect(fixture.deleted).toEqual([
|
||||
`${STATIC_CACHE_PREFIX}${"a".repeat(16)}`,
|
||||
`${STATIC_CACHE_PREFIX}${"b".repeat(16)}`,
|
||||
]);
|
||||
expect(source.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "CACHE_RESET_RESULT",
|
||||
nonce: "reset-1",
|
||||
cachesDeleted: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("service worker static asset install", () => {
|
||||
const manifest: StaticAssetManifestV1 = {
|
||||
schemaVersion: 1,
|
||||
buildId: "worker-build",
|
||||
releaseId: "release-1",
|
||||
setDigest: `sha256:${"a".repeat(64)}`,
|
||||
assets: [
|
||||
{
|
||||
url: `${ORIGIN}/assets/app.js`,
|
||||
sha256: `sha256:${"b".repeat(64)}`,
|
||||
bytes: 1,
|
||||
contentType: "application/javascript",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("aborts and rolls back a candidate cache at the overall install deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deleteCache = vi.fn(async () => true);
|
||||
const fetcher = vi.fn(
|
||||
(_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Install deadline", "AbortError"));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const result = installStaticAssets(manifest, {
|
||||
caches: {
|
||||
open: vi.fn(async () => ({ put: vi.fn() }) as unknown as Cache),
|
||||
delete: deleteCache,
|
||||
},
|
||||
fetcher: fetcher as typeof fetch,
|
||||
digest: vi.fn(),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(SERVICE_WORKER_BOUNDS.installDeadlineMs);
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
kind: "REJECTED",
|
||||
code: "INSTALL_DEADLINE_EXCEEDED",
|
||||
});
|
||||
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
|
||||
expect(deleteCache).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stops reading as soon as the streamed body exceeds declared bytes", async () => {
|
||||
const put = vi.fn(async () => {});
|
||||
const cancel = vi.fn(async () => {});
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2]));
|
||||
},
|
||||
cancel,
|
||||
});
|
||||
|
||||
await expect(
|
||||
installStaticAssets(manifest, {
|
||||
caches: {
|
||||
open: vi.fn(async () => ({ put }) as unknown as Cache),
|
||||
delete: vi.fn(async () => true),
|
||||
},
|
||||
fetcher: vi.fn(async () =>
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/javascript" },
|
||||
}),
|
||||
),
|
||||
digest: vi.fn(),
|
||||
}),
|
||||
).resolves.toEqual({ kind: "REJECTED", code: "BYTES_MISMATCH" });
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("aborts sibling asset fetches after the first install failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let siblingSignal: AbortSignal | undefined;
|
||||
const twoAssetManifest: StaticAssetManifestV1 = {
|
||||
...manifest,
|
||||
assets: [
|
||||
manifest.assets[0]!,
|
||||
{
|
||||
url: `${ORIGIN}/assets/chunk.js`,
|
||||
sha256: `sha256:${"c".repeat(64)}`,
|
||||
bytes: 1,
|
||||
contentType: "application/javascript",
|
||||
},
|
||||
],
|
||||
};
|
||||
const fetcher = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).endsWith("app.js")) {
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
siblingSignal = init?.signal ?? undefined;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
||||
return new Response(new Uint8Array([1]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/javascript" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const installing = installStaticAssets(twoAssetManifest, {
|
||||
caches: {
|
||||
open: vi.fn(async () => ({ put: vi.fn() }) as unknown as Cache),
|
||||
delete: vi.fn(async () => true),
|
||||
},
|
||||
fetcher: fetcher as typeof fetch,
|
||||
digest: vi.fn(async () => twoAssetManifest.assets[1]!.sha256),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
await expect(installing).resolves.toEqual({
|
||||
kind: "REJECTED",
|
||||
code: "STATUS_INVALID",
|
||||
});
|
||||
expect(siblingSignal?.aborted).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -219,6 +219,41 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
expect(harness.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a mandatory local reset when query cancellation fails", async () => {
|
||||
const client = createClient();
|
||||
client.setQueryData(["resource-a", 1, "list"], ["private-old-scope"]);
|
||||
vi.spyOn(client, "cancelQueries").mockRejectedValue(
|
||||
new Error("cancellation failed"),
|
||||
);
|
||||
const clear = vi.spyOn(client, "clear");
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
});
|
||||
|
||||
await expect(coordinator.resetLocal()).rejects.toThrow(
|
||||
"mandatory query cancellation failed",
|
||||
);
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("composes with no installed query topics", () => {
|
||||
// §24.12: removing the reference feature leaves the common runtime intact.
|
||||
// A template with no installed feature has zero invalidation topics, which
|
||||
// is a legitimate state, not a configuration defect.
|
||||
const harness = crossContextHarness();
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: createClient(),
|
||||
queryRegistry: Object.freeze({}),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
|
||||
// A remote hint for a topic this build does not install is ignored, not fatal.
|
||||
harness.deliver("qinv.topic-a");
|
||||
expect(() => coordinator.beginMutation([])).not.toThrow();
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
it("rejects an unregistered topic before opening a mutation lease", () => {
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: createClient(),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { IndexedDbRepositoryPort } from "../../src/application/ports/browser-file-storage/indexeddb-port.ts";
|
||||
import type {
|
||||
PushControlRepository,
|
||||
} from "../../src/adapters/web-push/push-association-fence-store.ts";
|
||||
import type { PushControlV1 } from "../../src/contracts/web-push.ts";
|
||||
|
||||
/**
|
||||
* The Web Push fence store declares its own narrow durable-store port so the
|
||||
* Web Push and browser file/storage capabilities stay independently removable.
|
||||
*
|
||||
* This suite is what keeps that decoupling honest: it asserts at type level
|
||||
* that the generic IndexedDB repository still satisfies the narrow port, so the
|
||||
* composition root can join the two without an adapter shim. It lives in the
|
||||
* browser-data import graph on purpose, so the storage removal harness drops it
|
||||
* along with the runtime it checks.
|
||||
*/
|
||||
describe("push control store port compatibility", () => {
|
||||
it("is satisfied by the generic IndexedDB repository", () => {
|
||||
type GenericRepository = IndexedDbRepositoryPort<PushControlV1, never>;
|
||||
const satisfiesNarrowPort = (
|
||||
repository: GenericRepository,
|
||||
): PushControlRepository => repository;
|
||||
|
||||
expect(typeof satisfiesNarrowPort).toBe("function");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user