Files
tech-log-frontend/tests/unit/runtime-adapters.test.ts
T
DongHyeonka 4566f2d7a8 refactor: make the public read port async so a network adapter can implement it
`PublicContentQueries` returned arrays, not promises. That signature is only
implementable by something already in memory, so the port could hold exactly
one adapter — the bundled fixture — and no amount of configuration could put
the public site on the backend. Turning it async is the change that makes a
second adapter possible; the adapter itself follows.

The markup is untouched. Every page reads a value and hands it to a
presentational component, so the shape those components receive is mapped at
the adapter boundary and nothing below the page changes.

Screens load through one query, not one per read. Several pages read in a loop
— the home timeline walks every project for its activity, the explore filter
walks search results to resolve titles — and a hook per read would mean a
variable number of hooks per render, which React forbids. `usePublicContent`
takes the whole screen's reads as one loader, where a loop is a loop and
`Promise.all` is available; the loops that used to be N sequential lookups now
issue together.

Two places deliberately do not show the loading surface. The explore filter
sits inside a page that already renders one, so a second skeleton would move
the layout under it — it keeps its structure and fills its options in when they
arrive. The search dialog is a type-ahead: re-querying per keystroke would
replace the results with a skeleton on every key, so it loads the catalog once
and applies the same predicate locally.

`usePublicContent` requires an object because `undefined` is how the query
layer says "no result yet". A loader returning the record itself would make a
missing slug indistinguishable from a request in flight, and the page would sit
on a skeleton instead of rendering its not-found route.

Studio's `resolvePublishedLabel` stays synchronous. It is called from inside
the public renderer, so making it async would push awaits through the render
tree; the shell loads the catalog once and the callback remains a lookup.

The component tests now assemble the query providers the running app assembles.
Without them the render throws "No QueryClient set" — not a harness quirk, but
the same failure the app would produce if it were mounted without its query
layer.
2026-08-20 16:53:51 +09:00

397 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
createRuntimeAdapters,
createRuntimeHttpClient,
} from "../../src/bootstrap/runtime-adapters.ts";
import { createBrowserMutationIntentFactory } from "../../src/adapters/platform/browser-mutation-intent-factory.ts";
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.ts";
type Runtime = Parameters<typeof createRuntimeAdapters>[0]["runtime"];
type Release = Parameters<typeof createRuntimeAdapters>[0]["release"];
const runtime: Runtime = {
config: {
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080",
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo",
REQUEST_TIMEOUT_MS: 4321,
MAX_RETRY_ATTEMPTS: 0,
RELEASE_MANIFEST_URL: "/release-manifest.json",
CONFIG_SCHEMA_VERSION: "2.0",
CAPABILITY_OVERRIDES: {
REALTIME: "DEFAULT",
WEB_WORKER: "DEFAULT",
SERVICE_WORKER: "DEFAULT",
OFFLINE_COMMANDS: "DEFAULT",
},
FEATURE_OVERRIDES: {},
TECH_LOG_STUDIO_SOURCE: "MOCK",
TECH_LOG_PUBLIC_SOURCE: "MOCK",
},
configSchema: "V2",
build: {
buildId: "build-a",
commitSha: "abc123",
routerBasePath: "/",
runtimeConfigUrl: "/config.json",
},
validationDurationMs: 0,
};
const release: Release = {
schemaVersion: 2,
appVersion: "0.1.0",
buildId: "build-a",
commitSha: "abc123",
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",
routeChunks: { "route-home": "assets/home.js" },
};
describe("runtime adapter composition", () => {
it("creates validated intent and idempotency identities with independent UUID calls", () => {
const randomUUID = vi
.fn<() => string>()
.mockReturnValueOnce("intent-uuid")
.mockReturnValueOnce("idempotency-uuid");
const factory = createBrowserMutationIntentFactory({
randomUUID,
monotonicNow: () => 12.5,
});
const intent = factory.create({
operationId: "CREATE_ENTITY",
canonicalInputIdentity: "opaque-canonical-input",
requiresIdempotencyKey: true,
});
expect(intent).toEqual({
intentId: "intent-uuid",
operationId: "CREATE_ENTITY",
canonicalInputIdentity: "opaque-canonical-input",
idempotencyKey: "idempotency-uuid",
createdAtMonotonicMs: 12.5,
});
expect(Object.isFrozen(intent)).toBe(true);
expect(randomUUID).toHaveBeenCalledTimes(2);
});
it("rejects invalid or unbounded mutation intent values", () => {
const factory = createBrowserMutationIntentFactory({
randomUUID: () => "opaque-runtime-identifier",
monotonicNow: () => 1,
});
expect(() =>
factory.create({
operationId: " ",
canonicalInputIdentity: "valid-identity",
requiresIdempotencyKey: false,
}),
).toThrow(TypeError);
expect(() =>
factory.create({
operationId: "CREATE_ENTITY",
canonicalInputIdentity: "x".repeat(16_385),
requiresIdempotencyKey: false,
}),
).toThrow(TypeError);
expect(() =>
createBrowserMutationIntentFactory({
randomUUID: () => "opaque-runtime-identifier",
monotonicNow: () => -1,
}).create({
operationId: "CREATE_ENTITY",
canonicalInputIdentity: "valid-identity",
requiresIdempotencyKey: false,
}),
).toThrow(TypeError);
});
it("constructs the local demo seam and infrastructure adapters", async () => {
const adapters = await createRuntimeAdapters({
runtime,
release,
host: {},
});
expect(adapters.outputPorts.session.getState()).toBe("unauthenticated");
await expect(adapters.outputPorts.releaseInfo.getCurrent()).resolves.toMatchObject({
releaseId: "release-a",
});
expect(adapters.infrastructure.queryClient).toBeDefined();
expect(adapters.infrastructure.queryInvalidation).toBeDefined();
expect(adapters.infrastructure.mutationIntentFactory).toBeDefined();
expect(
adapters.infrastructure.crossContextInvalidationStatus(),
).toBe("DEGRADED_LOCAL_ONLY");
expect(adapters.outputPorts.diagnostics.record).toEqual(expect.any(Function));
expect(adapters.outputPorts.telemetry.emit).toEqual(expect.any(Function));
expect(adapters.outputPorts.telemetry.pendingCount()).toBe(0);
expect(adapters).not.toHaveProperty("http");
expect(adapters).not.toHaveProperty("storage");
adapters.infrastructure.dispose();
});
it("runtime infrastructure disposal disposes telemetry first", async () => {
const fetcher = vi.fn(async () => new Response(null, { status: 204 }));
const adapters = await createRuntimeAdapters({
runtime: {
...runtime,
config: {
...runtime.config,
TELEMETRY_ENABLED: true,
TELEMETRY_ENDPOINT: "https://telemetry.test/events",
},
} as Runtime,
release,
host: {},
fetcher: fetcher as unknown as typeof fetch,
});
adapters.outputPorts.telemetry.emit("api.request.failed", {
error_kind: "SERVER_FAILURE",
http_status_group: "5xx",
attempt_count_bucket: "1",
route_id: "TEST_ROUTE",
});
expect(adapters.outputPorts.telemetry.pendingCount()).toBe(1);
adapters.infrastructure.dispose();
expect(adapters.outputPorts.telemetry.pendingCount()).toBe(0);
await Promise.resolve();
await Promise.resolve();
expect(fetcher).not.toHaveBeenCalled();
});
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 runtimeMutationIntentFactory =
adapters.infrastructure.mutationIntentFactory;
const clearPrevious = vi.spyOn(previousClient, "clear");
await adapters.outputPorts.session.beginSignIn();
await vi.waitFor(() =>
expect(adapters.infrastructure.queryClient).not.toBe(previousClient),
);
expect(adapters.infrastructure.queryInvalidation).not.toBe(
previousCoordinator,
);
expect(adapters.infrastructure.mutationIntentFactory).toBe(
runtimeMutationIntentFactory,
);
const clearCallsAfterReplacement = clearPrevious.mock.calls.length;
await previousCoordinator.resetLocal();
expect(clearPrevious).toHaveBeenCalledTimes(clearCallsAfterReplacement);
adapters.infrastructure.dispose();
});
it("does not fail boot when Web Storage capability getters throw", async () => {
const host: Record<string, unknown> = {};
Object.defineProperties(host, {
localStorage: {
get() {
throw new DOMException("denied", "SecurityError");
},
},
sessionStorage: {
get() {
throw new DOMException("denied", "SecurityError");
},
},
});
const adapters = await createRuntimeAdapters({
runtime,
release,
host,
});
expect(adapters.infrastructure.queryClient).toBeDefined();
expect(adapters.outputPorts.preferences.read("COLOR_SCHEME")).toEqual({
ok: true,
value: undefined,
});
adapters.infrastructure.dispose();
});
it("fails closed when an external auth owner was not installed", async () => {
const adapters = await createRuntimeAdapters({
runtime: {
...runtime,
config: { ...runtime.config, AUTH_MODE: "external" },
},
release,
host: {},
});
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",
},
FEATURE_OVERRIDES: {},
},
},
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,
buildId: "build-b",
releaseId: "release-b",
routeChunks: { "route-home": "assets/home-b.js" },
};
const fetcher = vi.fn(async () =>
new Response(JSON.stringify(activeRelease), {
headers: { "content-type": "application/json" },
}),
);
const adapters = await createRuntimeAdapters({
runtime,
release,
fetcher,
host: {},
});
await expect(adapters.outputPorts.releaseInfo.refresh()).resolves.toMatchObject({
buildId: "build-b",
releaseId: "release-b",
});
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 () => {
const scheduled: Array<{ callback: () => void; milliseconds: number }> = [];
const scheduler = {
setTimeout: vi.fn((callback: () => void, milliseconds: number) => {
scheduled.push({ callback, milliseconds });
return scheduled.length;
}),
clearTimeout: vi.fn(),
};
const fetcher = vi.fn(async () =>
Response.json(
{
success: false,
error: { code: "TEMPORARY" },
meta: { requestId: "request-1", traceId: "trace-1" },
},
{ status: 503 },
),
);
const authSession =
(await createRuntimeAdapters({
runtime,
release,
host: {},
})).outputPorts.session;
const client = createRuntimeHttpClient(
{
runtime,
authSession,
fetcher,
clock: { now: () => 0, sleep: async () => {} },
scheduler,
},
TEST_HTTP_CONTRACT,
);
await client.execute({
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
});
expect(fetcher).toHaveBeenCalledOnce();
expect(scheduler.setTimeout).toHaveBeenCalledWith(
expect.any(Function),
4321,
);
expect(scheduler.clearTimeout).toHaveBeenCalledOnce();
});
});