refactor: 리펙토링
This commit is contained in:
@@ -4,7 +4,7 @@ import {
|
||||
type Page,
|
||||
} from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
const TOPIC = "reference-resources";
|
||||
const TOPIC = "sample-topic-alpha";
|
||||
const TOPIC_VERSION = 1;
|
||||
const STATE_KEY = "__crossContextInvalidationCapability";
|
||||
const STORAGE_PULSE_KEY =
|
||||
|
||||
@@ -20,10 +20,44 @@ import {
|
||||
type QueryInvalidationCoordinator,
|
||||
} from "../../src/contracts/query-invalidation.ts";
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts";
|
||||
import {
|
||||
bindQuery,
|
||||
type QueryResultMeasure,
|
||||
} from "../../src/contracts/server-state.ts";
|
||||
import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts";
|
||||
|
||||
const RESOURCE_INVALIDATION_TOPIC =
|
||||
defineQueryInvalidationTopic("resource");
|
||||
|
||||
/**
|
||||
* §24.12: the scope-bound commit fence is common runtime, so it is verified
|
||||
* here with a local scope fixture rather than through the removable sample
|
||||
* feature.
|
||||
*/
|
||||
function scopeSnapshot(): CacheScopeSnapshot & { fence(): void } {
|
||||
let current = true;
|
||||
const lifetime = new AbortController();
|
||||
const identities = createRuntimeIdentityRegistry({
|
||||
tokenFactory: () => "scope-identity-token-0001",
|
||||
});
|
||||
return {
|
||||
generation: 1,
|
||||
fingerprint: "scope-fingerprint-0001",
|
||||
identities,
|
||||
signal: lifetime.signal,
|
||||
isCurrent: () => current,
|
||||
fence() {
|
||||
current = false;
|
||||
lifetime.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function measureOne(): QueryResultMeasure {
|
||||
return { itemCount: 1, estimatedBytes: 8 };
|
||||
}
|
||||
|
||||
function queryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -181,7 +215,303 @@ describe("application query inbound bridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope-bound query commit fence", () => {
|
||||
it("discards a successful result whose scope was fenced during execution", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
let complete: (value: ApplicationResult<string>) => void = () => {};
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery(
|
||||
bindQuery(
|
||||
{
|
||||
definitionId: "fenced-detail-v1",
|
||||
definitionVersion: 1,
|
||||
owner: "platform-test",
|
||||
namespace: "fenced",
|
||||
namespaceVersion: 1,
|
||||
operationId: "GET_FENCED",
|
||||
profileId: "DETAIL_STANDARD",
|
||||
measureResult: measureOne,
|
||||
execute: () =>
|
||||
new Promise<ApplicationResult<string>>((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
},
|
||||
"input",
|
||||
scope,
|
||||
),
|
||||
),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
// §10.8: the scope goes stale after dispatch but before commit.
|
||||
scope.fence();
|
||||
await act(async () => {
|
||||
complete({ ok: true, value: "late" });
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.failure?.kind).toBe(
|
||||
"SCOPE_GENERATION_CHANGED",
|
||||
),
|
||||
);
|
||||
expect(hook.result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses to start when the captured scope is already stale", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
const execute = vi.fn(async () => ({ ok: true as const, value: "v" }));
|
||||
scope.fence();
|
||||
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery(
|
||||
bindQuery(
|
||||
{
|
||||
definitionId: "stale-detail-v1",
|
||||
definitionVersion: 1,
|
||||
owner: "platform-test",
|
||||
namespace: "stale",
|
||||
namespaceVersion: 1,
|
||||
operationId: "GET_STALE",
|
||||
profileId: "DETAIL_STANDARD",
|
||||
measureResult: measureOne,
|
||||
execute,
|
||||
},
|
||||
"input",
|
||||
scope,
|
||||
),
|
||||
),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.failure?.kind).toBe(
|
||||
"SCOPE_GENERATION_CHANGED",
|
||||
),
|
||||
);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a result that exceeds the profile budget instead of caching it", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery(
|
||||
bindQuery(
|
||||
{
|
||||
definitionId: "oversized-list-v1",
|
||||
definitionVersion: 1,
|
||||
owner: "platform-test",
|
||||
namespace: "oversized",
|
||||
namespaceVersion: 1,
|
||||
operationId: "LIST_OVERSIZED",
|
||||
profileId: "VOLATILE_STATUS",
|
||||
// §10.4: VOLATILE_STATUS admits 1 item and 64KiB.
|
||||
measureResult: (): QueryResultMeasure => ({
|
||||
itemCount: 2,
|
||||
estimatedBytes: 8,
|
||||
}),
|
||||
execute: async () => ({ ok: true as const, value: "value" }),
|
||||
},
|
||||
"input",
|
||||
scope,
|
||||
),
|
||||
),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.failure?.kind).toBe(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
),
|
||||
);
|
||||
expect(hook.result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats a throwing measurement as a measurement failure, not a cache commit", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery(
|
||||
bindQuery(
|
||||
{
|
||||
definitionId: "unmeasurable-v1",
|
||||
definitionVersion: 1,
|
||||
owner: "platform-test",
|
||||
namespace: "unmeasurable",
|
||||
namespaceVersion: 1,
|
||||
operationId: "GET_UNMEASURABLE",
|
||||
profileId: "DETAIL_STANDARD",
|
||||
measureResult: (): QueryResultMeasure => {
|
||||
throw new Error("estimator defect");
|
||||
},
|
||||
execute: async () => ({ ok: true as const, value: "value" }),
|
||||
},
|
||||
"input",
|
||||
scope,
|
||||
),
|
||||
),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.failure?.kind).toBe(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
),
|
||||
);
|
||||
expect(hook.result.current.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope-bound mutation fence", () => {
|
||||
it("rejects a submit whose scope is already fenced", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
const execute = vi.fn(async () => ({ ok: true as const, value: "v" }));
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
definitionId: "fenced-mutation-v1",
|
||||
definitionVersion: 1,
|
||||
operationId: "CREATE_FENCED",
|
||||
owner: "platform-test",
|
||||
duplicatePolicy: "REJECT_WHILE_ACTIVE",
|
||||
scope,
|
||||
execute,
|
||||
invalidate: [],
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
scope.fence();
|
||||
const outcome = await hook.result.current.submit("value");
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SCOPE_GENERATION_CHANGED" },
|
||||
});
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a mutation result whose scope was fenced after dispatch", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
const execute = vi.fn(async () => {
|
||||
scope.fence();
|
||||
return { ok: true as const, value: "committed" };
|
||||
});
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
definitionId: "late-mutation-v1",
|
||||
definitionVersion: 1,
|
||||
operationId: "CREATE_LATE",
|
||||
owner: "platform-test",
|
||||
duplicatePolicy: "ALLOW_PARALLEL",
|
||||
scope,
|
||||
execute,
|
||||
invalidate: [],
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
const outcome = await hook.result.current.submit("value");
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SCOPE_GENERATION_CHANGED" },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("aborts a hung mutation when its captured scope is fenced", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
const execute = vi.fn(
|
||||
(_input: string, context: Readonly<{ signal: AbortSignal }>) =>
|
||||
new Promise<ApplicationResult<string>>((resolve) => {
|
||||
observedSignal = context.signal;
|
||||
context.signal.addEventListener(
|
||||
"abort",
|
||||
() =>
|
||||
resolve({
|
||||
ok: false,
|
||||
error: createFailure("REQUEST_ABORTED", "CREATE_HUNG", 0),
|
||||
}),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
definitionId: "hung-mutation-v1",
|
||||
definitionVersion: 1,
|
||||
operationId: "CREATE_HUNG",
|
||||
owner: "platform-test",
|
||||
duplicatePolicy: "REJECT_WHILE_ACTIVE",
|
||||
scope,
|
||||
execute,
|
||||
invalidate: [],
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
const outcome = hook.result.current.submit("value");
|
||||
await waitFor(() => expect(observedSignal).toBe(scope.signal));
|
||||
|
||||
scope.fence();
|
||||
|
||||
expect(observedSignal?.aborted).toBe(true);
|
||||
await expect(outcome).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "SCOPE_GENERATION_CHANGED",
|
||||
effect: "MAYBE_APPLIED",
|
||||
retryable: false,
|
||||
action: "contact-support",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("application mutation inbound bridge", () => {
|
||||
it("rejects a duplicate submit by default while one is active", async () => {
|
||||
const client = queryClient();
|
||||
let complete: (value: ApplicationResult<string>) => void = () => {};
|
||||
const execute = vi.fn(
|
||||
() =>
|
||||
new Promise<ApplicationResult<string>>((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() => useApplicationMutation<string, string>({ execute }),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let first: Promise<ApplicationResult<string>> | null = null;
|
||||
let duplicate: Promise<ApplicationResult<string>> | null = null;
|
||||
act(() => {
|
||||
first = hook.result.current.submit("created");
|
||||
duplicate = hook.result.current.submit("created");
|
||||
});
|
||||
|
||||
if (!first || !duplicate) throw new Error("expected two submissions");
|
||||
await expect(duplicate).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "DUPLICATE_IN_FLIGHT" },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
|
||||
complete({ ok: true, value: "created" });
|
||||
await act(() => first as Promise<ApplicationResult<string>>);
|
||||
});
|
||||
|
||||
it("deduplicates submit and commits one optimistic mutation", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "list"];
|
||||
@@ -196,6 +526,8 @@ describe("application mutation inbound bridge", () => {
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
// §11.2: joining is opt-in. The default is REJECT_WHILE_ACTIVE.
|
||||
duplicatePolicy: "JOIN_IDENTICAL",
|
||||
execute,
|
||||
invalidate: [RESOURCE_INVALIDATION_TOPIC],
|
||||
currentData: client.getQueryData(key),
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SERVER_STATE_PROFILES } from "../../src/contracts/server-state.ts";
|
||||
import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../../src/features/installed-contract-contributions.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
import PlatformOverviewPage from "../../src/presentation/examples/platform-overview-page.tsx";
|
||||
import { LocaleProvider } from "../../src/presentation/i18n/index.ts";
|
||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
/**
|
||||
* The page must stay a projection of the installed registries. Every assertion
|
||||
* below derives its expectation from the same registry the page reads, so a
|
||||
* template that removes its sample feature still satisfies this suite.
|
||||
*/
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<ApplicationProvider application={createTestApplication()}>
|
||||
<LocaleProvider>
|
||||
<PlatformOverviewPage />
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function tableByCaption(caption: string): HTMLElement {
|
||||
return screen.getByRole("table", { name: caption });
|
||||
}
|
||||
|
||||
/** A metric is a `dt`/`dd` pair, which carries no ARIA role to query by. */
|
||||
function metricByLabel(scope: HTMLElement, label: string): HTMLElement {
|
||||
const term = within(scope).getByText(label).closest(".platform-metric");
|
||||
if (!(term instanceof HTMLElement)) {
|
||||
throw new Error(`No metric is labelled ${label}`);
|
||||
}
|
||||
return term;
|
||||
}
|
||||
|
||||
describe("platform overview page", () => {
|
||||
it("renders one route row per installed route registry entry", () => {
|
||||
renderPage();
|
||||
|
||||
const table = tableByCaption("설치된 라우트 목록");
|
||||
const dataRows = within(table).getAllByRole("row").slice(1);
|
||||
|
||||
expect(dataRows).toHaveLength(Object.keys(ROUTE_REGISTRY).length);
|
||||
for (const definition of Object.values(ROUTE_REGISTRY)) {
|
||||
expect(
|
||||
within(table).getByText(definition.routeId),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders one operation row per installed HTTP contract", () => {
|
||||
const operationIds = [
|
||||
...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.keys(),
|
||||
];
|
||||
renderPage();
|
||||
|
||||
if (operationIds.length === 0) {
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "설치된 HTTP 오퍼레이션이 없습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
const table = tableByCaption("설치된 HTTP 오퍼레이션");
|
||||
expect(within(table).getAllByRole("row").slice(1)).toHaveLength(
|
||||
operationIds.length,
|
||||
);
|
||||
for (const operationId of operationIds) {
|
||||
expect(within(table).getByText(operationId)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders every fixed server-state profile with its own budget", () => {
|
||||
renderPage();
|
||||
|
||||
const table = tableByCaption("서버 상태 프로파일");
|
||||
for (const profile of Object.values(SERVER_STATE_PROFILES)) {
|
||||
expect(within(table).getByText(profile.profileId)).toBeInTheDocument();
|
||||
}
|
||||
expect(within(table).getAllByRole("row").slice(1)).toHaveLength(
|
||||
Object.keys(SERVER_STATE_PROFILES).length,
|
||||
);
|
||||
});
|
||||
|
||||
it("labels a capability that was never selected as unselected", () => {
|
||||
renderPage();
|
||||
|
||||
const region = screen.getByRole("region", { name: "선택적 런타임 능력" });
|
||||
|
||||
expect(within(region).getAllByText("미선택")).toHaveLength(4);
|
||||
expect(within(region).queryByText("운영자가 비활성화함")).toBeNull();
|
||||
});
|
||||
|
||||
it("separates an operator disable from a capability that was never selected", () => {
|
||||
render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
runtimeCapabilities: createRuntimeCapabilitiesStub({
|
||||
SERVICE_WORKER: { selected: 1, active: 0, override: "DISABLED" },
|
||||
OFFLINE_COMMANDS: { selected: 1, active: 1 },
|
||||
}),
|
||||
})}
|
||||
>
|
||||
<LocaleProvider>
|
||||
<PlatformOverviewPage />
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
|
||||
const region = screen.getByRole("region", { name: "선택적 런타임 능력" });
|
||||
|
||||
expect(within(region).getByText("운영자가 비활성화함")).toBeVisible();
|
||||
expect(within(region).getByText("활성 (1)")).toBeVisible();
|
||||
expect(within(region).getAllByText("미선택")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("counts installed contract packages separately from template fixtures", () => {
|
||||
const fixtures = COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter(
|
||||
(contribution) => contribution.source.kind === "TEMPLATE_FIXTURE",
|
||||
).length;
|
||||
renderPage();
|
||||
|
||||
const summary = screen.getByRole("region", { name: "설치 요약" });
|
||||
const packages = metricByLabel(summary, "외부 계약 패키지");
|
||||
|
||||
expect(
|
||||
within(packages).getByText(
|
||||
`${COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages.length}개`,
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(packages).getByText(`템플릿 픽스처 ${fixtures}개`),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(metricByLabel(summary, "라우트")).getByText(
|
||||
`${Object.keys(ROUTE_REGISTRY).length}개`,
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("renders the verified release identity once the runtime resolves it", async () => {
|
||||
renderPage();
|
||||
|
||||
const region = screen.getByRole("region", { name: "릴리스 신원" });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(metricByLabel(region, "빌드")).getByText("test-build"),
|
||||
).toBeVisible(),
|
||||
);
|
||||
expect(
|
||||
within(metricByLabel(region, "릴리스")).getByText("test-release"),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -22,6 +22,29 @@ function renderRouter() {
|
||||
}
|
||||
|
||||
describe("generic application router", () => {
|
||||
it("reaches the platform overview from the home starter actions", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/");
|
||||
renderRouter();
|
||||
|
||||
// The starter action lives inside the lazily loaded home chunk, so this is
|
||||
// the first wait in the file that has to outlast a chunk load rather than
|
||||
// an already-mounted shell element.
|
||||
await user.click(
|
||||
await screen.findByRole(
|
||||
"link",
|
||||
{ name: "플랫폼 구성 보기" },
|
||||
{ timeout: 5000 },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "플랫폼 구성", level: 1 }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the app shell and not-found route without a feature input", async () => {
|
||||
window.history.pushState({}, "", "/missing");
|
||||
renderRouter();
|
||||
@@ -49,8 +72,29 @@ describe("generic application router", () => {
|
||||
).toBeVisible();
|
||||
expect(window.location.pathname).toBe("/examples/ui");
|
||||
expect(document.title).toBe("UI 구성요소 · Frontend Skeleton");
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
|
||||
).toHaveFocus();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("focuses the route heading again when the lazy chunk is already cached", async () => {
|
||||
// §9.7. The first visit resolves the route module asynchronously, so the
|
||||
// router lifecycle and the page header commit separately. A later visit
|
||||
// renders the cached module in the same commit, which is the ordering that
|
||||
// must still hand focus to the heading rather than leaving it on main.
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/");
|
||||
renderRouter();
|
||||
|
||||
for (const label of ["UI 구성요소", "화면 상태", "UI 구성요소"]) {
|
||||
await user.click(await screen.findByRole("link", { name: label }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: label, level: 1 }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createServerStateScopeRuntime } from "../../src/adapters/query-cache/server-state-scope-runtime.ts";
|
||||
import { createServerStateGenerationStore } from "../../src/bootstrap/server-state-generation-store.ts";
|
||||
import { ServerStateGenerationProvider } from "../../src/presentation/adapters/query/server-state-generation-provider.tsx";
|
||||
|
||||
describe("server-state generation provider", () => {
|
||||
it("remounts consumers with the QueryClient owned by the READY generation", async () => {
|
||||
const store = createServerStateGenerationStore(() => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return {
|
||||
queryClient,
|
||||
queryInvalidation: {
|
||||
invalidate: async () => {},
|
||||
beginMutation: () => ({ release: async () => {} }),
|
||||
resetLocal: async () => {
|
||||
await queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
},
|
||||
dispose() {},
|
||||
},
|
||||
crossContextStatus: () => "DEGRADED_LOCAL_ONLY" as const,
|
||||
};
|
||||
});
|
||||
let sessionListener: () => void = () => {};
|
||||
let token = 0;
|
||||
const scope = createServerStateScopeRuntime({
|
||||
session: {
|
||||
subscribe(listener) {
|
||||
sessionListener = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
queryInvalidation: { resetLocal: () => store.resetCurrent() },
|
||||
activateNextGeneration: () => store.activateNext(),
|
||||
tokenFactory: () =>
|
||||
`scope-generation-provider-${String(token++).padStart(4, "0")}`,
|
||||
});
|
||||
const renderedClients: QueryClient[] = [];
|
||||
function Probe() {
|
||||
renderedClients.push(useQueryClient());
|
||||
return <div>generation-content</div>;
|
||||
}
|
||||
|
||||
render(
|
||||
<ServerStateGenerationProvider
|
||||
store={store}
|
||||
scope={scope}
|
||||
transitionFallback={<div>scope-transition</div>}
|
||||
>
|
||||
<Probe />
|
||||
</ServerStateGenerationProvider>,
|
||||
);
|
||||
const firstClient = renderedClients.at(-1);
|
||||
if (!firstClient) throw new Error("expected initial QueryClient");
|
||||
|
||||
act(() => sessionListener());
|
||||
|
||||
await waitFor(() => expect(scope.getPhase()).toBe("READY"));
|
||||
await waitFor(() =>
|
||||
expect(renderedClients.at(-1)).toBe(store.getSnapshot().queryClient),
|
||||
);
|
||||
expect(renderedClients.at(-1)).not.toBe(firstClient);
|
||||
|
||||
scope.dispose();
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createServerStateScopeRuntime } from "../../src/adapters/query-cache/server-state-scope-runtime.ts";
|
||||
import { ServerStateScopeProvider } from "../../src/presentation/adapters/query/server-state-scope-provider.tsx";
|
||||
|
||||
const activeRuntimes: Array<{ dispose(): void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const runtime of activeRuntimes.splice(0)) runtime.dispose();
|
||||
});
|
||||
|
||||
function scopeFixture(resetLocal: () => Promise<void>) {
|
||||
let sessionListener: () => void = () => {};
|
||||
let token = 0;
|
||||
const runtime = createServerStateScopeRuntime({
|
||||
session: {
|
||||
subscribe(listener) {
|
||||
sessionListener = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
queryInvalidation: {
|
||||
resetLocal,
|
||||
},
|
||||
tokenFactory: () => `scope-provider-token-${String(token++).padStart(4, "0")}`,
|
||||
});
|
||||
activeRuntimes.push(runtime);
|
||||
return { runtime, triggerSessionChange: () => sessionListener() };
|
||||
}
|
||||
|
||||
describe("server-state scope provider", () => {
|
||||
it("removes previous-scope children synchronously while reset is pending", async () => {
|
||||
let completeReset: () => void = () => {};
|
||||
const reset = new Promise<void>((resolve) => {
|
||||
completeReset = resolve;
|
||||
});
|
||||
const fixture = scopeFixture(async () => reset);
|
||||
|
||||
render(
|
||||
<ServerStateScopeProvider
|
||||
runtime={fixture.runtime}
|
||||
transitionFallback={<div>scope-transition</div>}
|
||||
>
|
||||
<div>previous-account-secret</div>
|
||||
</ServerStateScopeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("previous-account-secret")).toBeVisible();
|
||||
|
||||
act(() => fixture.triggerSessionChange());
|
||||
|
||||
expect(screen.queryByText("previous-account-secret")).toBeNull();
|
||||
expect(screen.getByText("scope-transition")).toBeVisible();
|
||||
|
||||
completeReset();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("previous-account-secret")).toBeVisible(),
|
||||
);
|
||||
});
|
||||
|
||||
it("never remounts previous-scope children after mandatory cleanup failure", async () => {
|
||||
const fixture = scopeFixture(async () => {
|
||||
throw new Error("reset failed");
|
||||
});
|
||||
render(
|
||||
<ServerStateScopeProvider
|
||||
runtime={fixture.runtime}
|
||||
transitionFallback={<div>scope-transition</div>}
|
||||
>
|
||||
<div>previous-account-secret</div>
|
||||
</ServerStateScopeProvider>,
|
||||
);
|
||||
|
||||
act(() => fixture.triggerSessionChange());
|
||||
|
||||
await waitFor(() => expect(fixture.runtime.getPhase()).toBe("FAILED"));
|
||||
expect(screen.queryByText("previous-account-secret")).toBeNull();
|
||||
expect(screen.getByText("scope-transition")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ test("@a11y keyboard reaches the primary route action with visible focus", async
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const action = page.getByRole("link", { name: "UI 구성요소 보기" });
|
||||
const action = page.getByRole("link", { name: "플랫폼 구성 보기" });
|
||||
await expect(action).toBeVisible();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(action).toBeFocused();
|
||||
|
||||
@@ -6,8 +6,14 @@ test("boots and navigates the compact production shell", async ({ page }) => {
|
||||
const menu = page.getByRole("button", { name: "메뉴", exact: true });
|
||||
await expect(menu).toHaveCSS("min-width", "44px");
|
||||
await menu.click();
|
||||
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
await page.getByRole("link", { name: "UI 구성요소" }).click();
|
||||
const navigation = page.getByRole("navigation", { name: "주요 탐색" });
|
||||
await expect(navigation).toBeVisible();
|
||||
// The drawer is a non-modal dialog, so page content stays in the
|
||||
// accessibility tree while it is open. Scope to the navigation and match the
|
||||
// whole name, or a route call to action on the page behind it also matches.
|
||||
await navigation
|
||||
.getByRole("link", { name: "UI 구성요소", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/examples\/ui$/);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-build-id", "local-build");
|
||||
const overflow = await page.evaluate(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import { SERVER_STATE_PROFILES } from "../../src/contracts/server-state.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
/**
|
||||
* The overview is a projection of the installed registries. These assertions
|
||||
* read the same registries the shipped bundle was built from, so they keep
|
||||
* meaning after a feature is added or removed.
|
||||
*/
|
||||
|
||||
test("projects the installed route registry into the shipped bundle", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/examples/platform");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "플랫폼 구성", level: 1 }),
|
||||
).toBeVisible();
|
||||
|
||||
const table = page.getByRole("table", { name: "설치된 라우트 목록" });
|
||||
await expect(table.locator("tbody tr")).toHaveCount(
|
||||
Object.keys(ROUTE_REGISTRY).length,
|
||||
);
|
||||
for (const routeId of Object.keys(ROUTE_REGISTRY)) {
|
||||
await expect(table.getByText(routeId, { exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows every fixed server-state profile", async ({ page }) => {
|
||||
await page.goto("/examples/platform");
|
||||
|
||||
const table = page.getByRole("table", { name: "서버 상태 프로파일" });
|
||||
await expect(table.locator("tbody tr")).toHaveCount(
|
||||
Object.keys(SERVER_STATE_PROFILES).length,
|
||||
);
|
||||
});
|
||||
|
||||
test("states the release contract identity verified at boot", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/examples/platform");
|
||||
|
||||
const release = page.locator(
|
||||
"section[aria-labelledby='platform-release-title']",
|
||||
);
|
||||
await expect(release.getByText(/^sha256:/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("reports an unselected capability without claiming it was disabled", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/examples/platform");
|
||||
|
||||
const capabilities = page.locator(
|
||||
"section[aria-labelledby='platform-capabilities-title']",
|
||||
);
|
||||
await expect(capabilities.getByText("미선택")).toHaveCount(4);
|
||||
await expect(capabilities.getByText("운영자가 비활성화함")).toHaveCount(0);
|
||||
});
|
||||
@@ -1,25 +1,21 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import { successEnvelope } from "../mocks/contracts/envelopes.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
test("opens the protected integration route through the local demo seam", async ({
|
||||
page,
|
||||
}) => {
|
||||
const protectedRoute = Object.values(ROUTE_REGISTRY).find(
|
||||
(definition) => definition.access === "integration-defined",
|
||||
);
|
||||
if (!protectedRoute) throw new Error("An integration route is required");
|
||||
const protectedRoute = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST;
|
||||
await page.route(
|
||||
"http://localhost:8080/api/reference-resources?*",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: successEnvelope([
|
||||
json: [
|
||||
{
|
||||
id: "browser-reference",
|
||||
name: "Browser reference",
|
||||
createdAt: "2026-07-26T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.goto(protectedRoute.path);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildRouteUrl,
|
||||
@@ -19,8 +19,34 @@ import {
|
||||
} from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts";
|
||||
import type { ReferenceFeatureInput } from "../../../src/features/reference-feature/application/reference-feature-api.ts";
|
||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
|
||||
|
||||
describe("reference feature boundary contracts", () => {
|
||||
it("propagates uncertain command effect certainty into the application failure", async () => {
|
||||
const execute = vi.fn(async () => ({
|
||||
kind: "CONTRACT_VIOLATION" as const,
|
||||
violation: {
|
||||
kind: "SUCCESS_SCHEMA_INVALID" as const,
|
||||
operation: "VALIDATION" as const,
|
||||
},
|
||||
effect: "MAYBE_APPLIED" as const,
|
||||
}));
|
||||
const installed = createReferenceFeatureInstalledInput({
|
||||
contractOperations: { execute },
|
||||
});
|
||||
|
||||
await expect(
|
||||
installed.input.createResource({ name: "uncertain" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
effect: "MAYBE_APPLIED",
|
||||
retryable: false,
|
||||
action: "contact-support",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips one canonical filter through URL and query identity", () => {
|
||||
const filters = {
|
||||
tags: ["open", "new"],
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"total": {
|
||||
"lines": { "pct": 0 },
|
||||
"statements": { "pct": 0 },
|
||||
"functions": { "pct": 0 },
|
||||
"branches": { "pct": 0 }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ApplicationOutputPorts,
|
||||
} from "../../src/application/create-application.ts";
|
||||
import type { ApplicationFeatureInputs } from "../../src/application/ports/in/application-api.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "./runtime-capabilities-stub.ts";
|
||||
|
||||
type TestApplicationOverrides = Partial<ApplicationOutputPorts> &
|
||||
Readonly<{
|
||||
@@ -49,6 +50,8 @@ export function createTestApplication(
|
||||
},
|
||||
}),
|
||||
},
|
||||
runtimeCapabilities:
|
||||
overrides.runtimeCapabilities ?? createRuntimeCapabilitiesStub(),
|
||||
navigation: overrides.navigation ?? { reload: () => {} },
|
||||
},
|
||||
overrides.featureInputs,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type {
|
||||
RuntimeCapabilityId,
|
||||
RuntimeCapabilityStatus,
|
||||
} from "../../src/contracts/runtime-capabilities.ts";
|
||||
import type { RuntimeCapabilitiesPort } from "../../src/application/ports/runtime-capabilities-port.ts";
|
||||
|
||||
const CAPABILITY_IDS = Object.freeze([
|
||||
"REALTIME",
|
||||
"WEB_WORKER",
|
||||
"SERVICE_WORKER",
|
||||
"OFFLINE_COMMANDS",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* A capability port whose snapshot is fixed by the test rather than by the
|
||||
* repository's installed selection, so a suite asserting presentation behaviour
|
||||
* does not change meaning when a capability is later installed.
|
||||
*/
|
||||
export function createRuntimeCapabilitiesStub(
|
||||
overrides: Readonly<Partial<Record<RuntimeCapabilityId, Partial<RuntimeCapabilityStatus>>>> = {},
|
||||
): RuntimeCapabilitiesPort {
|
||||
const snapshot = Object.freeze(
|
||||
CAPABILITY_IDS.map((capabilityId) =>
|
||||
Object.freeze({
|
||||
capabilityId,
|
||||
selected: 0,
|
||||
active: 0,
|
||||
override: "DEFAULT" as const,
|
||||
...overrides[capabilityId],
|
||||
}),
|
||||
),
|
||||
);
|
||||
return Object.freeze({ getSnapshot: () => snapshot });
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import { delay, http, HttpResponse, type JsonBodyType } from "msw";
|
||||
|
||||
import {
|
||||
failureEnvelope,
|
||||
successEnvelope,
|
||||
} from "../contracts/envelopes.ts";
|
||||
import type { HttpScenarioId } from "../scenarios/catalog.ts";
|
||||
import { assertOperationScenario } from "../scenarios/catalog.ts";
|
||||
|
||||
@@ -36,7 +32,7 @@ const DEFAULT_RESOURCE = Object.freeze({
|
||||
|
||||
async function scenarioResponse(
|
||||
scenario: HttpScenarioId,
|
||||
payload: unknown,
|
||||
payload: JsonBodyType,
|
||||
attempt: number,
|
||||
) {
|
||||
if (scenario === "slow") await delay(50);
|
||||
@@ -56,35 +52,33 @@ async function scenarioResponse(
|
||||
return HttpResponse.json({ data: payload });
|
||||
}
|
||||
if (scenario === "schema-mismatch") {
|
||||
return HttpResponse.json(successEnvelope({ unexpected: true }));
|
||||
return HttpResponse.json({ unexpected: true });
|
||||
}
|
||||
if (
|
||||
scenario === "auth-persistent-401" ||
|
||||
(scenario === "auth-recover-once" && attempt === 1)
|
||||
) {
|
||||
return HttpResponse.json(failureEnvelope("AUTH_REQUIRED"), {
|
||||
return HttpResponse.json(problem(401, "AUTH_REQUIRED"), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
if (scenario === "forbidden-403") {
|
||||
return HttpResponse.json(failureEnvelope("FORBIDDEN"), { status: 403 });
|
||||
return HttpResponse.json(problem(403, "FORBIDDEN"), { status: 403 });
|
||||
}
|
||||
if (scenario === "not-found-404") {
|
||||
return HttpResponse.json(failureEnvelope("NOT_FOUND"), { status: 404 });
|
||||
return HttpResponse.json(problem(404, "NOT_FOUND"), { status: 404 });
|
||||
}
|
||||
if (scenario === "conflict-409") {
|
||||
return HttpResponse.json(failureEnvelope("CONFLICT"), { status: 409 });
|
||||
return HttpResponse.json(problem(409, "CONFLICT"), { status: 409 });
|
||||
}
|
||||
if (scenario === "validation-422") {
|
||||
return HttpResponse.json(
|
||||
failureEnvelope("VALIDATION_REJECTED", {
|
||||
issues: [{ path: "name", code: "too_small" }],
|
||||
}),
|
||||
problem(422, "VALIDATION_REJECTED"),
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
if (scenario === "rate-limited-429") {
|
||||
return HttpResponse.json(failureEnvelope("RATE_LIMITED"), {
|
||||
return HttpResponse.json(problem(429, "RATE_LIMITED"), {
|
||||
status: 429,
|
||||
headers: { "Retry-After": "1" },
|
||||
});
|
||||
@@ -93,11 +87,20 @@ async function scenarioResponse(
|
||||
scenario === "server-terminal-500" ||
|
||||
(scenario === "server-retry-success" && attempt === 1)
|
||||
) {
|
||||
return HttpResponse.json(failureEnvelope("SERVER_FAILURE"), {
|
||||
return HttpResponse.json(problem(503, "SERVER_FAILURE"), {
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
return HttpResponse.json(successEnvelope(payload));
|
||||
return HttpResponse.json(payload);
|
||||
}
|
||||
|
||||
function problem(status: number, code: string) {
|
||||
return Object.freeze({
|
||||
type: `https://api.test/problems/${code.toLowerCase()}`,
|
||||
title: code,
|
||||
status,
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
export function createReferenceScenarioHandlers(options: ScenarioOptions = {}) {
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
loadReleaseManifest,
|
||||
ReleaseManifestError,
|
||||
} from "../../src/bootstrap/load-release-manifest.ts";
|
||||
import { computeContractSetDigest } from "../../src/contracts/contract-set-canonical.ts";
|
||||
|
||||
const EMPTY_SET_DIGEST = await computeContractSetDigest([]);
|
||||
|
||||
const runtime: Parameters<typeof loadReleaseManifest>[0] = {
|
||||
build: {
|
||||
@@ -22,12 +25,45 @@ const runtime: Parameters<typeof loadReleaseManifest>[0] = {
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
BUILD_ID: "build-a",
|
||||
RELEASE_ID: "release-a",
|
||||
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",
|
||||
validationDurationMs: 0,
|
||||
};
|
||||
const manifest = {
|
||||
schemaVersion: 2,
|
||||
appVersion: "0.1.0",
|
||||
buildId: "build-a",
|
||||
commitSha: "abc123",
|
||||
configSchemaVersion: "2.0",
|
||||
assetManifestHash: "hash-a",
|
||||
releaseId: "release-a",
|
||||
builtAt: "2026-07-25T00:00:00Z",
|
||||
routeChunks: { "route-home": "assets/home.js" },
|
||||
contractSet: {
|
||||
setAlgorithm: "CA_CONTRACT_SET_V1",
|
||||
setDigest: EMPTY_SET_DIGEST,
|
||||
packages: [],
|
||||
},
|
||||
};
|
||||
|
||||
const runtimeV1: Parameters<typeof loadReleaseManifest>[0] = {
|
||||
...runtime,
|
||||
config: {
|
||||
...runtime.config,
|
||||
CONFIG_SCHEMA_VERSION: "1",
|
||||
LEGACY_API_CONTRACT_VERSION: "1",
|
||||
},
|
||||
configSchema: "V1",
|
||||
};
|
||||
|
||||
const manifestV1 = {
|
||||
schemaVersion: 1,
|
||||
appVersion: "0.1.0",
|
||||
buildId: "build-a",
|
||||
@@ -40,12 +76,18 @@ const manifest = {
|
||||
routeChunks: { "route-home": "assets/home.js" },
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("release manifest boot boundary", () => {
|
||||
it("loads a coherent release tuple", async () => {
|
||||
await expect(
|
||||
loadReleaseManifest(
|
||||
runtime,
|
||||
{ fetcher: async () => new Response(JSON.stringify(manifest)) },
|
||||
{ fetcher: async () => jsonResponse(manifest) },
|
||||
),
|
||||
).resolves.toMatchObject({ releaseId: "release-a" });
|
||||
});
|
||||
@@ -56,7 +98,7 @@ describe("release manifest boot boundary", () => {
|
||||
runtime,
|
||||
{
|
||||
fetcher: async () =>
|
||||
new Response(JSON.stringify({ ...manifest, buildId: "build-b" })),
|
||||
jsonResponse({ ...manifest, buildId: "build-b" }),
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
@@ -66,18 +108,6 @@ describe("release manifest boot boundary", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
{ configSchemaVersion: "2" },
|
||||
{},
|
||||
"CONFIG_MISMATCH",
|
||||
"MANIFEST_CONFIG_SCHEMA_MISMATCH",
|
||||
],
|
||||
[
|
||||
{ apiContractVersion: "2" },
|
||||
{},
|
||||
"API_CONTRACT_MISMATCH",
|
||||
"MANIFEST_API_CONTRACT_MISMATCH",
|
||||
],
|
||||
[
|
||||
{ releaseId: "release-b" },
|
||||
{},
|
||||
@@ -98,7 +128,7 @@ describe("release manifest boot boundary", () => {
|
||||
runtime,
|
||||
{
|
||||
fetcher: async () =>
|
||||
Response.json({ ...manifest, ...manifestOverride }),
|
||||
jsonResponse({ ...manifest, ...manifestOverride }),
|
||||
...options,
|
||||
},
|
||||
),
|
||||
@@ -106,6 +136,98 @@ describe("release manifest boot boundary", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["3.0", "9.0"])(
|
||||
"rejects unsupported V2 manifest config version %s at the schema boundary",
|
||||
async (configSchemaVersion) => {
|
||||
await expect(
|
||||
loadReleaseManifest(runtime, {
|
||||
fetcher: async () =>
|
||||
jsonResponse({ ...manifest, configSchemaVersion }),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
kind: "RELEASE_MANIFEST_FAILURE",
|
||||
code: "MANIFEST_SCHEMA_INVALID",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a contract set the build did not compile", async () => {
|
||||
await expect(
|
||||
loadReleaseManifest(runtime, {
|
||||
fetcher: async () =>
|
||||
jsonResponse({
|
||||
...manifest,
|
||||
contractSet: {
|
||||
setAlgorithm: "CA_CONTRACT_SET_V1",
|
||||
setDigest: EMPTY_SET_DIGEST,
|
||||
packages: [
|
||||
{
|
||||
packageId: "@org-contracts/worklog",
|
||||
version: "1.2.3",
|
||||
digest: `sha256:${"a".repeat(64)}`,
|
||||
runtimeProtocolVersion: 1,
|
||||
sourceRevision: "abc1234",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
kind: "CONTRACT_SET_MISMATCH",
|
||||
code: "CONTRACT_SET_PACKAGE_UNEXPECTED",
|
||||
});
|
||||
});
|
||||
|
||||
it("still reads a V1 manifest during the compatibility window", async () => {
|
||||
await expect(
|
||||
loadReleaseManifest(
|
||||
runtimeV1,
|
||||
{
|
||||
fetcher: async () => jsonResponse(manifestV1),
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({ schemaVersion: 1, contractSet: null });
|
||||
});
|
||||
|
||||
it("rejects a V2 runtime paired with a V1 manifest", async () => {
|
||||
await expect(
|
||||
loadReleaseManifest(runtime, {
|
||||
fetcher: async () => jsonResponse({
|
||||
...manifestV1,
|
||||
configSchemaVersion: "2.0",
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
kind: "PROTOCOL_PAIR_MISMATCH",
|
||||
code: "MANIFEST_PROTOCOL_PAIR_MISMATCH",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a V1 runtime paired with a V2 manifest", async () => {
|
||||
await expect(
|
||||
loadReleaseManifest(runtimeV1, {
|
||||
fetcher: async () => jsonResponse(manifest),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
kind: "PROTOCOL_PAIR_MISMATCH",
|
||||
code: "MANIFEST_PROTOCOL_PAIR_MISMATCH",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires matching legacy scalar versions for a V1 pair", async () => {
|
||||
await expect(
|
||||
loadReleaseManifest(runtimeV1, {
|
||||
fetcher: async () => jsonResponse({
|
||||
...manifestV1,
|
||||
apiContractVersion: "2",
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
kind: "API_CONTRACT_MISMATCH",
|
||||
code: "MANIFEST_API_CONTRACT_MISMATCH",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a manifest without a complete route chunk map", async () => {
|
||||
const malformed = Object.fromEntries(
|
||||
Object.entries(manifest).filter(([key]) => key !== "routeChunks"),
|
||||
@@ -113,7 +235,7 @@ describe("release manifest boot boundary", () => {
|
||||
await expect(
|
||||
loadReleaseManifest(
|
||||
runtime,
|
||||
{ fetcher: async () => Response.json(malformed) },
|
||||
{ fetcher: async () => jsonResponse(malformed) },
|
||||
),
|
||||
).rejects.toBeInstanceOf(ReleaseManifestError);
|
||||
});
|
||||
|
||||
@@ -6,17 +6,22 @@ import { assertSafeConfigNames } from "../../src/contracts/env.ts";
|
||||
|
||||
const validConfig = {
|
||||
APP_ENV: "local",
|
||||
API_BASE_URL: "http://localhost:8080",
|
||||
API_BASE_URL: "http://localhost:8080/",
|
||||
REQUEST_TIMEOUT_MS: 10_000,
|
||||
MAX_RETRY_ATTEMPTS: 2,
|
||||
TELEMETRY_ENABLED: false,
|
||||
AUTH_MODE: "external",
|
||||
CONFIG_SCHEMA_VERSION: "1",
|
||||
API_CONTRACT_VERSION: "1",
|
||||
CONFIG_SCHEMA_VERSION: "2.0",
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
BUILD_ID: "build-a",
|
||||
};
|
||||
|
||||
const validV1Config = {
|
||||
...validConfig,
|
||||
CONFIG_SCHEMA_VERSION: "1",
|
||||
API_CONTRACT_VERSION: "1.4.0",
|
||||
};
|
||||
|
||||
describe("runtime configuration boundary", () => {
|
||||
it.each([
|
||||
[{ ...validConfig, API_BASE_URL: undefined }, "required key"],
|
||||
@@ -24,6 +29,30 @@ describe("runtime configuration boundary", () => {
|
||||
[{ ...validConfig, MAX_RETRY_ATTEMPTS: 3 }, "retry cap"],
|
||||
[{ ...validConfig, TELEMETRY_ENABLED: "false" }, "ambiguous boolean"],
|
||||
[{ ...validConfig, CONFIG_SCHEMA_VERSION: "next" }, "version"],
|
||||
[
|
||||
{ ...validConfig, API_CONTRACT_VERSION: "1" },
|
||||
"§5.1 scalar contract version is removed from V2",
|
||||
],
|
||||
[
|
||||
{ ...validConfig, API_BASE_URL: "http://localhost:8080/api" },
|
||||
"§6.2 base URL path must end with /",
|
||||
],
|
||||
[
|
||||
{ ...validConfig, API_BASE_URL: "http://localhost:8080/#frag" },
|
||||
"§6.2 hash in base URL",
|
||||
],
|
||||
[
|
||||
{ ...validConfig, API_BASE_URL: "http://user:pw@localhost:8080/" },
|
||||
"§6.2 credentials in URL",
|
||||
],
|
||||
[
|
||||
{ ...validConfig, RELEASE_MANIFEST_URL: "/a/../b.json" },
|
||||
"§6.2 dot segment traversal",
|
||||
],
|
||||
[
|
||||
{ ...validConfig, RELEASE_MANIFEST_URL: "/manifest.json?v=1" },
|
||||
"§6.2 query in manifest URL",
|
||||
],
|
||||
[{ ...validConfig, UNKNOWN_KEY: true }, "unknown key"],
|
||||
])("rejects invalid config: %s (%s)", (candidate, _reason) => {
|
||||
void _reason;
|
||||
@@ -44,12 +73,55 @@ describe("runtime configuration boundary", () => {
|
||||
validateRuntimeConfig({
|
||||
...validConfig,
|
||||
APP_ENV: "production",
|
||||
API_BASE_URL: "https://api.example.test",
|
||||
API_BASE_URL: "https://api.example.test/",
|
||||
AUTH_MODE: "demo",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only the explicitly supported V1 and V2 boot versions", () => {
|
||||
expect(validateRuntimeConfig(validV1Config)).toMatchObject({
|
||||
success: true,
|
||||
schema: "V1",
|
||||
});
|
||||
expect(validateRuntimeConfig(validConfig)).toMatchObject({
|
||||
success: true,
|
||||
schema: "V2",
|
||||
});
|
||||
|
||||
for (const version of ["0", "1.0", "2.0.1", "3.0"]) {
|
||||
expect(
|
||||
validateRuntimeConfig({
|
||||
...validV1Config,
|
||||
CONFIG_SCHEMA_VERSION: version,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"file:///tmp/api/",
|
||||
"data:text/plain,/",
|
||||
"blob:https://example.test/00000000-0000-0000-0000-000000000000",
|
||||
])("rejects a non-HTTP API endpoint in local mode: %s", (API_BASE_URL) => {
|
||||
expect(validateRuntimeConfig({ ...validConfig, API_BASE_URL }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["file:///tmp/telemetry", "data:text/plain,telemetry"])(
|
||||
"rejects a non-HTTP telemetry endpoint in local mode: %s",
|
||||
(TELEMETRY_ENDPOINT) => {
|
||||
expect(
|
||||
validateRuntimeConfig({
|
||||
...validConfig,
|
||||
TELEMETRY_ENABLED: true,
|
||||
TELEMETRY_ENDPOINT,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("validates a fetched config under the 500ms budget excluding network", async () => {
|
||||
let current = 100;
|
||||
const result = await loadRuntimeConfig({
|
||||
@@ -59,12 +131,38 @@ describe("runtime configuration boundary", () => {
|
||||
routerBasePath: "/",
|
||||
runtimeConfigUrl: "/config.json",
|
||||
},
|
||||
fetcher: async () => new Response(JSON.stringify(validConfig)),
|
||||
fetcher: async () =>
|
||||
new Response(JSON.stringify(validConfig), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
now: () => (current += 2),
|
||||
});
|
||||
|
||||
expect(result.validationDurationMs).toBeLessThanOrEqual(500);
|
||||
expect(result.config.API_BASE_URL).toBe("http://localhost:8080");
|
||||
expect(result.config.API_BASE_URL).toBe("http://localhost:8080/");
|
||||
expect(result.configSchema).toBe("V2");
|
||||
expect(result.config.CAPABILITY_OVERRIDES.SERVICE_WORKER).toBe("DEFAULT");
|
||||
});
|
||||
|
||||
it("does not include network acquisition in validationDurationMs", async () => {
|
||||
let current = 0;
|
||||
const result = await loadRuntimeConfig({
|
||||
buildConfig: {
|
||||
buildId: "build-a",
|
||||
commitSha: "local",
|
||||
routerBasePath: "/",
|
||||
runtimeConfigUrl: "/config.json",
|
||||
},
|
||||
fetcher: async () => {
|
||||
current = 10_000;
|
||||
return new Response(JSON.stringify(validConfig), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
now: () => current,
|
||||
});
|
||||
|
||||
expect(result.validationDurationMs).toBe(0);
|
||||
});
|
||||
|
||||
it("returns only safe boot fields on failure", async () => {
|
||||
@@ -76,7 +174,10 @@ describe("runtime configuration boundary", () => {
|
||||
routerBasePath: "/",
|
||||
runtimeConfigUrl: "/config.json",
|
||||
},
|
||||
fetcher: async () => new Response("{"),
|
||||
fetcher: async () =>
|
||||
new Response("{", {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
safe: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 24 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 91 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 396 KiB |
@@ -41,3 +41,14 @@ test("loading empty error and access surfaces visual contract", async ({
|
||||
"state-surfaces-light.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("platform composition overview visual contract", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 1100 });
|
||||
await page.goto("/examples/platform");
|
||||
await expect(
|
||||
page.locator("section[aria-labelledby='platform-capabilities-title']"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("main")).toHaveScreenshot(
|
||||
"platform-overview-light.png",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user