chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("selects, inspects, streams, and previews a real browser File", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(async () => {
|
||||
const modulePath =
|
||||
"/src/adapters/browser-files/index.ts";
|
||||
const {
|
||||
createBrowserFileRuntime,
|
||||
browserFilePolicyReference,
|
||||
} = await import(/* @vite-ignore */ modulePath);
|
||||
const input = document.createElement("input");
|
||||
input.id = "capability-file-input";
|
||||
input.type = "file";
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = input.id;
|
||||
label.textContent = "PNG file";
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = "Choose test file";
|
||||
document.body.append(label, input, button);
|
||||
|
||||
const filePolicy = browserFilePolicyReference(
|
||||
"avatar",
|
||||
"select-inspect-preview-png",
|
||||
);
|
||||
const runtime = createBrowserFileRuntime({
|
||||
input,
|
||||
policies: [
|
||||
{
|
||||
reference: filePolicy,
|
||||
selection: {
|
||||
policyId: "avatar-v1",
|
||||
purpose: "avatar",
|
||||
classification: "PERSONAL",
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxFileBytes: 1024,
|
||||
maxTotalBytes: 1024,
|
||||
allowEmpty: false,
|
||||
accept: [
|
||||
{
|
||||
mediaType: "image/png",
|
||||
extensions: [".png"],
|
||||
},
|
||||
],
|
||||
},
|
||||
inspection: {
|
||||
policyId: "png-signature-v1",
|
||||
maxInspectionBytes: 16,
|
||||
acceptedSignatures: [
|
||||
{
|
||||
mediaType: "image/png",
|
||||
extensions: [".png"],
|
||||
patterns: [
|
||||
{
|
||||
offset: 0,
|
||||
bytes: [137, 80, 78, 71, 13, 10, 26, 10],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
preview: {
|
||||
allowedMediaTypes: ["image/png"],
|
||||
maxPreviewBytes: 1024,
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: {
|
||||
hardMaxPreviewBytes: 1024,
|
||||
hardMaxObjectUrlBytes: 1024,
|
||||
hardMaxTransferBytes: 4096,
|
||||
},
|
||||
download: {
|
||||
host: Object.freeze({ handoff() {} }),
|
||||
browserManagedCapabilities: {
|
||||
resolve() {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: "POLICY_REJECTED" as const,
|
||||
operation: "DOWNLOAD" as const,
|
||||
retryable: false,
|
||||
recovery: "NONE" as const,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = globalThis as typeof globalThis & {
|
||||
__browserFileResult?: Promise<unknown>;
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
state.__browserFileResult = (async () => {
|
||||
const selected = await runtime.baselinePicker.select({
|
||||
policy: filePolicy,
|
||||
});
|
||||
if (
|
||||
!selected.ok ||
|
||||
selected.value.kind !== "SELECTED" ||
|
||||
selected.value.files.length !== 1
|
||||
) {
|
||||
runtime.dispose();
|
||||
return selected;
|
||||
}
|
||||
const candidate = selected.value.files[0]!;
|
||||
const signal = new AbortController().signal;
|
||||
const inspected = await runtime.content.inspect({
|
||||
ref: candidate.ref,
|
||||
policy: filePolicy,
|
||||
signal,
|
||||
});
|
||||
const range = await runtime.content.readRange({
|
||||
ref: candidate.ref,
|
||||
offset: 1,
|
||||
length: 4,
|
||||
signal,
|
||||
});
|
||||
const opened = await runtime.content.openSource({
|
||||
ref: candidate.ref,
|
||||
signal,
|
||||
});
|
||||
const streamed: number[] = [];
|
||||
if (opened.ok) {
|
||||
for await (const chunk of opened.value.stream(signal)) {
|
||||
if (chunk.ok) streamed.push(...chunk.value);
|
||||
}
|
||||
}
|
||||
const verificationReceipt = inspected.ok
|
||||
? inspected.value.verificationReceipt
|
||||
: null;
|
||||
if (!verificationReceipt) {
|
||||
runtime.dispose();
|
||||
return inspected;
|
||||
}
|
||||
const preview = await runtime.previews.create({
|
||||
ref: candidate.ref,
|
||||
verificationReceipt,
|
||||
policy: filePolicy,
|
||||
maxPreviewBytes: 1024,
|
||||
signal,
|
||||
});
|
||||
let previewBytes: number[] = [];
|
||||
let previewProtocol: string | null = null;
|
||||
if (preview.ok) {
|
||||
previewProtocol = new URL(preview.value.url).protocol;
|
||||
previewBytes = Array.from(
|
||||
new Uint8Array(await (await fetch(preview.value.url)).arrayBuffer()),
|
||||
);
|
||||
preview.value.release();
|
||||
}
|
||||
runtime.baselinePicker.release(candidate.ref);
|
||||
runtime.dispose();
|
||||
return {
|
||||
selected,
|
||||
inspected,
|
||||
range: range.ok ? Array.from(range.value) : range,
|
||||
streamed,
|
||||
preview: preview.ok ? { ok: true } : preview,
|
||||
previewBytes,
|
||||
previewProtocol,
|
||||
};
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
const chooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "Choose test file" }).click();
|
||||
const chooser = await chooserPromise;
|
||||
const bytes = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4]);
|
||||
await chooser.setFiles({
|
||||
name: "sample.png",
|
||||
mimeType: "image/png",
|
||||
buffer: bytes,
|
||||
});
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const state = globalThis as typeof globalThis & {
|
||||
__browserFileResult?: Promise<unknown>;
|
||||
};
|
||||
return await state.__browserFileResult;
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
selected: {
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "SELECTED",
|
||||
files: [{ sizeBytes: bytes.byteLength, source: "NATIVE_INPUT" }],
|
||||
},
|
||||
},
|
||||
inspected: {
|
||||
ok: true,
|
||||
value: {
|
||||
byteLength: bytes.byteLength,
|
||||
detectedMediaType: "image/png",
|
||||
signature: "MATCHED",
|
||||
},
|
||||
},
|
||||
range: [80, 78, 71, 13],
|
||||
streamed: Array.from(bytes),
|
||||
preview: { ok: true },
|
||||
previewBytes: Array.from(bytes),
|
||||
previewProtocol: "blob:",
|
||||
});
|
||||
});
|
||||
|
||||
test("hands a bounded generated Blob to the real browser download path", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(async () => {
|
||||
const modulePath = "/src/adapters/browser-files/index.ts";
|
||||
const {
|
||||
createBrowserFileRuntime,
|
||||
createAnchorDownloadHost,
|
||||
browserFilePolicyReference,
|
||||
} = await import(/* @vite-ignore */ modulePath);
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.id = "unused-download-input";
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = input.id;
|
||||
label.textContent = "Unused file control";
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = "Download generated file";
|
||||
document.body.append(label, input, button);
|
||||
const downloadPolicy = browserFilePolicyReference(
|
||||
"generated-export",
|
||||
"bounded-object-url-bin",
|
||||
);
|
||||
const runtime = createBrowserFileRuntime({
|
||||
input,
|
||||
policies: [
|
||||
{
|
||||
reference: downloadPolicy,
|
||||
download: {
|
||||
strategy: "BOUNDED_OBJECT_URL",
|
||||
mediaType: "application/octet-stream",
|
||||
safeExtension: ".bin",
|
||||
maxTransferBytes: 1024,
|
||||
maxBufferedBytes: 1024,
|
||||
integrity: "OPTIONAL",
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: {
|
||||
hardMaxPreviewBytes: 1024,
|
||||
hardMaxObjectUrlBytes: 1024,
|
||||
hardMaxTransferBytes: 4096,
|
||||
},
|
||||
download: {
|
||||
host: createAnchorDownloadHost(document),
|
||||
browserManagedCapabilities: {
|
||||
resolve() {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: "POLICY_REJECTED" as const,
|
||||
operation: "DOWNLOAD" as const,
|
||||
retryable: false,
|
||||
recovery: "NONE" as const,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = globalThis as typeof globalThis & {
|
||||
__browserDownloadResult?: Promise<unknown>;
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
state.__browserDownloadResult = runtime.downloads
|
||||
.deliver({
|
||||
policy: downloadPolicy,
|
||||
source: {
|
||||
kind: "GENERATED",
|
||||
bytes: {
|
||||
byteLength: 5,
|
||||
async *stream() {
|
||||
yield {
|
||||
ok: true as const,
|
||||
value: new Uint8Array([1, 2]),
|
||||
};
|
||||
yield {
|
||||
ok: true as const,
|
||||
value: new Uint8Array([3, 4, 5]),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
suggestedFileName: "report",
|
||||
maxTransferBytes: 1024,
|
||||
maxBufferedBytes: 1024,
|
||||
signal: new AbortController().signal,
|
||||
onProgress() {},
|
||||
})
|
||||
.finally(() => runtime.dispose());
|
||||
});
|
||||
});
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page
|
||||
.getByRole("button", { name: "Download generated file" })
|
||||
.click();
|
||||
const download = await downloadPromise;
|
||||
const stream = await download.createReadStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
const result = await page.evaluate(async () => {
|
||||
const state = globalThis as typeof globalThis & {
|
||||
__browserDownloadResult?: Promise<unknown>;
|
||||
};
|
||||
return await state.__browserDownloadResult;
|
||||
});
|
||||
|
||||
expect(download.suggestedFilename()).toBe("report.bin");
|
||||
expect(Buffer.concat(chunks)).toEqual(Buffer.from([1, 2, 3, 4, 5]));
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "BROWSER_HANDOFF",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type Page,
|
||||
} from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
const TOPIC = "sample-topic-alpha";
|
||||
const TOPIC_VERSION = 1;
|
||||
const STATE_KEY = "__crossContextInvalidationCapability";
|
||||
const STORAGE_PULSE_KEY =
|
||||
"ca-frontend:cache-invalidation:v1:pulse";
|
||||
|
||||
type BrowserTransportMode = "BROADCAST" | "STORAGE";
|
||||
|
||||
type BrowserDeliverySnapshot = Readonly<{
|
||||
ordering: string;
|
||||
sequence: number;
|
||||
topic: string;
|
||||
transport: string;
|
||||
}>;
|
||||
|
||||
type BrowserRuntimeSnapshot = Readonly<{
|
||||
status: string;
|
||||
deliveries: readonly BrowserDeliverySnapshot[];
|
||||
pulseRetained: boolean;
|
||||
}>;
|
||||
|
||||
async function installRuntime(
|
||||
page: Page,
|
||||
mode: BrowserTransportMode,
|
||||
cacheEpoch: string,
|
||||
): Promise<string> {
|
||||
return await page.evaluate(
|
||||
async ({
|
||||
currentCacheEpoch,
|
||||
currentMode,
|
||||
stateKey,
|
||||
topic,
|
||||
topicVersion,
|
||||
}) => {
|
||||
const modulePath =
|
||||
"/src/adapters/cross-context-invalidation/index.ts";
|
||||
const { createBrowserCrossContextInvalidationFromHost } =
|
||||
(await import(
|
||||
/* @vite-ignore */ modulePath
|
||||
)) as typeof import("../../src/adapters/cross-context-invalidation/index.ts");
|
||||
|
||||
const host: Record<string, unknown> =
|
||||
currentMode === "BROADCAST"
|
||||
? (globalThis as unknown as Record<string, unknown>)
|
||||
: {
|
||||
crypto: globalThis.crypto,
|
||||
localStorage: globalThis.localStorage,
|
||||
addEventListener:
|
||||
globalThis.addEventListener.bind(globalThis),
|
||||
removeEventListener:
|
||||
globalThis.removeEventListener.bind(globalThis),
|
||||
};
|
||||
const runtime = createBrowserCrossContextInvalidationFromHost({
|
||||
host,
|
||||
cacheEpoch: currentCacheEpoch,
|
||||
topics: [{ topic, topicVersion }],
|
||||
});
|
||||
if (!runtime) {
|
||||
throw new Error(
|
||||
"Cross-context invalidation runtime is unavailable.",
|
||||
);
|
||||
}
|
||||
const deliveries: BrowserDeliverySnapshot[] = [];
|
||||
runtime.subscribe((delivery) => {
|
||||
deliveries.push({
|
||||
ordering: delivery.ordering,
|
||||
sequence: delivery.event.sequence,
|
||||
topic: delivery.event.topic,
|
||||
transport: delivery.transport,
|
||||
});
|
||||
});
|
||||
Reflect.set(globalThis, stateKey, { deliveries, runtime });
|
||||
return runtime.getStatus();
|
||||
},
|
||||
{
|
||||
currentCacheEpoch: cacheEpoch,
|
||||
currentMode: mode,
|
||||
stateKey: STATE_KEY,
|
||||
topic: TOPIC,
|
||||
topicVersion: TOPIC_VERSION,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function publish(page: Page) {
|
||||
return await page.evaluate(
|
||||
({ stateKey, topic, topicVersion }) => {
|
||||
const state = Reflect.get(globalThis, stateKey) as
|
||||
| {
|
||||
runtime: {
|
||||
publish(input: {
|
||||
topic: string;
|
||||
topicVersion: number;
|
||||
}): unknown;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
if (!state) throw new Error("Capability runtime is not installed.");
|
||||
return state.runtime.publish({ topic, topicVersion });
|
||||
},
|
||||
{
|
||||
stateKey: STATE_KEY,
|
||||
topic: TOPIC,
|
||||
topicVersion: TOPIC_VERSION,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function snapshot(page: Page): Promise<BrowserRuntimeSnapshot> {
|
||||
return await page.evaluate(
|
||||
({ pulseKey, stateKey }) => {
|
||||
const state = Reflect.get(globalThis, stateKey) as
|
||||
| {
|
||||
deliveries: BrowserDeliverySnapshot[];
|
||||
runtime: { getStatus(): string };
|
||||
}
|
||||
| undefined;
|
||||
if (!state) throw new Error("Capability runtime is not installed.");
|
||||
return {
|
||||
status: state.runtime.getStatus(),
|
||||
deliveries: structuredClone(state.deliveries),
|
||||
pulseRetained: localStorage.getItem(pulseKey) !== null,
|
||||
};
|
||||
},
|
||||
{ pulseKey: STORAGE_PULSE_KEY, stateKey: STATE_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
async function closeRuntime(page: Page): Promise<string | null> {
|
||||
return await page.evaluate((stateKey) => {
|
||||
const state = Reflect.get(globalThis, stateKey) as
|
||||
| { runtime: { close(): void; getStatus(): string } }
|
||||
| undefined;
|
||||
if (!state) return null;
|
||||
state.runtime.close();
|
||||
return state.runtime.getStatus();
|
||||
}, STATE_KEY);
|
||||
}
|
||||
|
||||
async function disposeRuntime(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ pulseKey, stateKey }) => {
|
||||
const state = Reflect.get(globalThis, stateKey) as
|
||||
| { runtime: { close(): void } }
|
||||
| undefined;
|
||||
state?.runtime.close();
|
||||
localStorage.removeItem(pulseKey);
|
||||
Reflect.deleteProperty(globalThis, stateKey);
|
||||
},
|
||||
{ pulseKey: STORAGE_PULSE_KEY, stateKey: STATE_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
test("delivers invalidation through native BroadcastChannel and cleans the receiver", async ({
|
||||
page,
|
||||
}) => {
|
||||
const secondPage = await page.context().newPage();
|
||||
const cacheEpoch = `browser.primary.${Date.now()}`;
|
||||
try {
|
||||
await Promise.all([page.goto("/"), secondPage.goto("/")]);
|
||||
await page.evaluate(
|
||||
(pulseKey) => localStorage.removeItem(pulseKey),
|
||||
STORAGE_PULSE_KEY,
|
||||
);
|
||||
const statuses = await Promise.all([
|
||||
installRuntime(page, "BROADCAST", cacheEpoch),
|
||||
installRuntime(secondPage, "BROADCAST", cacheEpoch),
|
||||
]);
|
||||
expect(statuses).toEqual([
|
||||
"ACTIVE_BROADCAST",
|
||||
"ACTIVE_BROADCAST",
|
||||
]);
|
||||
|
||||
expect(await publish(page)).toEqual({
|
||||
ok: true,
|
||||
transport: "BROADCAST",
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await snapshot(secondPage)).deliveries)
|
||||
.toEqual([
|
||||
{
|
||||
ordering: "NEXT",
|
||||
sequence: 1,
|
||||
topic: TOPIC,
|
||||
transport: "BROADCAST",
|
||||
},
|
||||
]);
|
||||
expect((await snapshot(page)).deliveries).toEqual([]);
|
||||
|
||||
expect(await closeRuntime(secondPage)).toBe("CLOSED");
|
||||
expect(await publish(page)).toEqual({
|
||||
ok: true,
|
||||
transport: "BROADCAST",
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
expect(await snapshot(secondPage)).toEqual({
|
||||
status: "CLOSED",
|
||||
deliveries: [
|
||||
{
|
||||
ordering: "NEXT",
|
||||
sequence: 1,
|
||||
topic: TOPIC,
|
||||
transport: "BROADCAST",
|
||||
},
|
||||
],
|
||||
pulseRetained: false,
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
disposeRuntime(page),
|
||||
disposeRuntime(secondPage),
|
||||
]);
|
||||
await secondPage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to native localStorage events and removes the pulse during cleanup", async ({
|
||||
page,
|
||||
}) => {
|
||||
const secondPage = await page.context().newPage();
|
||||
const cacheEpoch = `browser.fallback.${Date.now()}`;
|
||||
try {
|
||||
await Promise.all([page.goto("/"), secondPage.goto("/")]);
|
||||
await page.evaluate(
|
||||
(pulseKey) => localStorage.removeItem(pulseKey),
|
||||
STORAGE_PULSE_KEY,
|
||||
);
|
||||
const statuses = await Promise.all([
|
||||
installRuntime(page, "STORAGE", cacheEpoch),
|
||||
installRuntime(secondPage, "STORAGE", cacheEpoch),
|
||||
]);
|
||||
expect(statuses).toEqual([
|
||||
"ACTIVE_STORAGE_FALLBACK",
|
||||
"ACTIVE_STORAGE_FALLBACK",
|
||||
]);
|
||||
|
||||
expect(await publish(page)).toEqual({
|
||||
ok: true,
|
||||
transport: "STORAGE",
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await snapshot(secondPage)).deliveries)
|
||||
.toEqual([
|
||||
{
|
||||
ordering: "NEXT",
|
||||
sequence: 1,
|
||||
topic: TOPIC,
|
||||
transport: "STORAGE",
|
||||
},
|
||||
]);
|
||||
expect(await snapshot(page)).toMatchObject({
|
||||
deliveries: [],
|
||||
pulseRetained: false,
|
||||
});
|
||||
expect(await snapshot(secondPage)).toMatchObject({
|
||||
pulseRetained: false,
|
||||
});
|
||||
|
||||
expect(await closeRuntime(secondPage)).toBe("CLOSED");
|
||||
expect(await publish(page)).toEqual({
|
||||
ok: true,
|
||||
transport: "STORAGE",
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
expect(await snapshot(secondPage)).toEqual({
|
||||
status: "CLOSED",
|
||||
deliveries: [
|
||||
{
|
||||
ordering: "NEXT",
|
||||
sequence: 1,
|
||||
topic: TOPIC,
|
||||
transport: "STORAGE",
|
||||
},
|
||||
],
|
||||
pulseRetained: false,
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
disposeRuntime(page),
|
||||
disposeRuntime(secondPage),
|
||||
]);
|
||||
await secondPage.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
test("fetches and decodes an allowlisted public immutable CDN image", async ({
|
||||
page,
|
||||
}) => {
|
||||
const cdnRequests: string[] = [];
|
||||
await page.route("https://images.example.test/**", async (route) => {
|
||||
cdnRequests.push(route.request().url());
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: PNG_1X1,
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control":
|
||||
"public, max-age=31536000, s-maxage=31536000, immutable",
|
||||
"content-length": String(PNG_1X1.byteLength),
|
||||
"content-type": "image/png",
|
||||
vary: "Accept-Encoding",
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const modulePath =
|
||||
"/src/adapters/browser-transfer/image-cdn/index.ts";
|
||||
const {
|
||||
ImageCdnPolicyRegistry,
|
||||
createBrowserImageProbe,
|
||||
createImageCdnRuntime,
|
||||
imageCdnPresetReference,
|
||||
} = (await import(
|
||||
/* @vite-ignore */ modulePath
|
||||
)) as typeof import("../../src/adapters/browser-transfer/image-cdn/index.ts");
|
||||
|
||||
const preset = imageCdnPresetReference(
|
||||
"browser-probe",
|
||||
"verify-public-cdn-image",
|
||||
);
|
||||
const policies = new ImageCdnPolicyRegistry({
|
||||
applicationOrigin: "https://app.example.test",
|
||||
origins: [
|
||||
{
|
||||
originKey: "browser-images",
|
||||
origin: "https://images.example.test",
|
||||
assetPathPrefix: "/v1/assets/",
|
||||
minimumPublicMaxAgeSeconds: 31_536_000,
|
||||
},
|
||||
],
|
||||
presets: [
|
||||
{
|
||||
reference: preset,
|
||||
bindingId: "browser-probe-v1",
|
||||
width: 1,
|
||||
height: 1,
|
||||
fit: "cover",
|
||||
dprs: [1],
|
||||
responsiveWidths: [1],
|
||||
quality: 90,
|
||||
formats: ["png"],
|
||||
sizes: "1px",
|
||||
loading: "eager",
|
||||
decoding: "async",
|
||||
fetchPriority: "high",
|
||||
referrerPolicy: "no-referrer",
|
||||
probeMode: "PRIMARY_REQUIRED",
|
||||
allowUpscale: false,
|
||||
maxTransformedPixels: 1,
|
||||
maxDecodedBytes: 4,
|
||||
maxEncodedBytes: 128,
|
||||
},
|
||||
],
|
||||
hardLimits: {
|
||||
maxIntrinsicWidth: 1,
|
||||
maxIntrinsicHeight: 1,
|
||||
maxSourcePixels: 1,
|
||||
maxCssDimension: 1,
|
||||
maxDpr: 1,
|
||||
maxQuality: 90,
|
||||
maxCandidateCount: 1,
|
||||
maxTransformedPixels: 1,
|
||||
maxDecodedBytes: 4,
|
||||
maxEncodedBytes: 128,
|
||||
maxUrlLength: 2_048,
|
||||
maxCapabilityLifetimeMs: 60_000,
|
||||
maxClockSkewMs: 1_000,
|
||||
minCapabilityRemainingMs: 1_000,
|
||||
maxPresetBindingsPerCapability: 1,
|
||||
maxConcurrentCapabilityVerifications: 1,
|
||||
allowedSourceMediaTypes: ["image/png"],
|
||||
formatQualityCeilings: { png: 90 },
|
||||
},
|
||||
capability: {
|
||||
issuer: "browser-image-bff",
|
||||
acceptedKeyIds: ["browser-image-key-v1"],
|
||||
},
|
||||
});
|
||||
const runtime = createImageCdnRuntime({
|
||||
policies,
|
||||
probe: createBrowserImageProbe(),
|
||||
});
|
||||
const accepted = runtime.assets.acceptPublicImmutable({
|
||||
kind: "ALLOWLISTED_PUBLIC",
|
||||
originKey: "browser-images",
|
||||
assetId: "asset_1x1",
|
||||
revision: "revision_1",
|
||||
mediaType: "image/png",
|
||||
contentKind: "RASTER_STATIC",
|
||||
intrinsicWidth: 1,
|
||||
intrinsicHeight: 1,
|
||||
});
|
||||
if (!accepted.ok) {
|
||||
runtime.close();
|
||||
return {
|
||||
accepted,
|
||||
resolved: null,
|
||||
};
|
||||
}
|
||||
const resolved = await runtime.presentation.resolve({
|
||||
asset: accepted.value,
|
||||
preset,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
runtime.close();
|
||||
return {
|
||||
accepted: { ok: true as const },
|
||||
resolved,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.accepted).toEqual({ ok: true });
|
||||
expect(result.resolved).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
fallbackMediaType: "image/png",
|
||||
srcSet: expect.stringMatching(/ 1w$/u),
|
||||
sources: [],
|
||||
loading: "eager",
|
||||
decoding: "async",
|
||||
fetchPriority: "high",
|
||||
referrerPolicy: "no-referrer",
|
||||
crossOrigin: "anonymous",
|
||||
delivery: {
|
||||
class: "PUBLIC_IMMUTABLE",
|
||||
assetVersion: "revision_1",
|
||||
browserCache: "PUBLIC_IMMUTABLE",
|
||||
sharedCache: "PUBLIC_IMMUTABLE",
|
||||
purge: "REVISION_ROLLOVER",
|
||||
expiresAtEpochMs: null,
|
||||
},
|
||||
decodeBudget: {
|
||||
maximumCandidatePixels: 1,
|
||||
maximumDecodedBytes: 4,
|
||||
maximumEncodedBytes: 128,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!result.resolved?.ok) return;
|
||||
|
||||
const imageUrl = new URL(result.resolved.value.src);
|
||||
expect(imageUrl.origin).toBe("https://images.example.test");
|
||||
expect(imageUrl.pathname).toBe(
|
||||
"/v1/assets/asset_1x1/revision_1",
|
||||
);
|
||||
expect(Object.fromEntries(imageUrl.searchParams)).toEqual({
|
||||
dpr: "1",
|
||||
fit: "cover",
|
||||
format: "png",
|
||||
height: "1",
|
||||
preset: "browser-probe-v1",
|
||||
quality: "90",
|
||||
width: "1",
|
||||
});
|
||||
expect(cdnRequests).toEqual([result.resolved.value.src]);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("runs put, open, remove and reconcile through a native DedicatedWorker, OPFS and IndexedDB", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const runtimeModulePath =
|
||||
"/src/adapters/storage/opfs/browser-opfs-runtime.ts";
|
||||
const policyModulePath =
|
||||
"/src/adapters/storage/opfs/opfs-policy.ts";
|
||||
const journalModulePath =
|
||||
"/src/adapters/storage/opfs/indexeddb-opfs-journal.ts";
|
||||
const {
|
||||
createBrowserOpfsRuntime,
|
||||
inspectBrowserOpfsSupport,
|
||||
} = (await import(
|
||||
/* @vite-ignore */ runtimeModulePath
|
||||
)) as typeof import("../../src/adapters/storage/opfs/browser-opfs-runtime.ts");
|
||||
const {
|
||||
DEFAULT_OPFS_RUNTIME_POLICY,
|
||||
resolveOpfsRuntimePolicy,
|
||||
} = (await import(
|
||||
/* @vite-ignore */ policyModulePath
|
||||
)) as typeof import("../../src/adapters/storage/opfs/opfs-policy.ts");
|
||||
const { opfsJournalDatabaseName } = (await import(
|
||||
/* @vite-ignore */ journalModulePath
|
||||
)) as typeof import("../../src/adapters/storage/opfs/indexeddb-opfs-journal.ts");
|
||||
const policy = resolveOpfsRuntimePolicy();
|
||||
const support = inspectBrowserOpfsSupport(policy);
|
||||
if (!support.ok) {
|
||||
return {
|
||||
nativeSupport: false as const,
|
||||
capabilities: support,
|
||||
};
|
||||
}
|
||||
|
||||
const suffix = crypto.randomUUID().replaceAll("-", "");
|
||||
const scope = {
|
||||
namespace: "durable-objects",
|
||||
authorityToken: `authority_${suffix}`,
|
||||
namespaceToken: `namespace_${suffix}`,
|
||||
partitionToken: `partition_${suffix}`,
|
||||
} as const;
|
||||
const storagePolicy = {
|
||||
owner: "browser-capability",
|
||||
namespace: scope.namespace,
|
||||
classification: "PERSONAL",
|
||||
authority: "LOCAL_FIRST",
|
||||
accountScope: "OPAQUE_PARTITION",
|
||||
retention: { kind: "EXPLICIT_DELETE" },
|
||||
softBudgetBytes: 1024 * 1024,
|
||||
hardBudgetBytes: 2 * 1024 * 1024,
|
||||
evictionPriority: "USER_AUTHORED",
|
||||
logoutAction: "EXPORT_THEN_PURGE",
|
||||
accountDeletionAction: "PURGE_PARTITION",
|
||||
pressureAction: "RETAIN",
|
||||
unavailableFallback: "EXPORT_REQUIRED",
|
||||
} as const;
|
||||
const runtime = createBrowserOpfsRuntime({
|
||||
workerUrl: new URL(
|
||||
"/tests/browser-capabilities/opfs-test.worker.ts",
|
||||
location.href,
|
||||
),
|
||||
workerName: `opfs-capability-${suffix}`,
|
||||
scope,
|
||||
storagePolicy,
|
||||
policy,
|
||||
});
|
||||
const bytes = new Uint8Array([3, 1, 4, 1, 5, 9]);
|
||||
let capabilities: unknown;
|
||||
let put: unknown;
|
||||
let opened:
|
||||
| Awaited<ReturnType<typeof runtime.objects.open>>
|
||||
| undefined;
|
||||
let readBytes: number[] = [];
|
||||
let removed: unknown;
|
||||
let afterRemove: unknown;
|
||||
let reconciled: unknown;
|
||||
try {
|
||||
capabilities = await runtime.objects.capabilities();
|
||||
put = await runtime.objects.put({
|
||||
objectId: "object_browser_12345678",
|
||||
expectedGeneration: null,
|
||||
mediaType: "application/octet-stream",
|
||||
source: {
|
||||
byteLength: bytes.byteLength,
|
||||
async *stream(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
yield {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: "ABORTED" as const,
|
||||
operation: "OBJECT_WRITE" as const,
|
||||
retryable: false,
|
||||
recovery: "NONE" as const,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
yield { ok: true as const, value: Uint8Array.from(bytes) };
|
||||
},
|
||||
},
|
||||
});
|
||||
opened = await runtime.objects.open({
|
||||
objectId: "object_browser_12345678",
|
||||
});
|
||||
if (
|
||||
opened &&
|
||||
typeof opened === "object" &&
|
||||
"ok" in opened &&
|
||||
opened.ok
|
||||
) {
|
||||
for await (const chunk of opened.value.source.stream(
|
||||
new AbortController().signal,
|
||||
)) {
|
||||
if (!chunk.ok) throw new Error(chunk.error.code);
|
||||
readBytes.push(...chunk.value);
|
||||
}
|
||||
}
|
||||
removed = await runtime.objects.remove({
|
||||
objectId: "object_browser_12345678",
|
||||
expectedGeneration: 1,
|
||||
});
|
||||
afterRemove = await runtime.objects.open({
|
||||
objectId: "object_browser_12345678",
|
||||
});
|
||||
reconciled = await runtime.maintenance.reconcile({
|
||||
budgetMs: 5_000,
|
||||
maxTransactions: 100,
|
||||
});
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
|
||||
const deletion = await new Promise<string>((resolve) => {
|
||||
const request = indexedDB.deleteDatabase(
|
||||
opfsJournalDatabaseName(scope.authorityToken),
|
||||
);
|
||||
request.onsuccess = () => resolve("DELETED");
|
||||
request.onerror = () =>
|
||||
resolve(request.error?.name ?? "UNKNOWN_ERROR");
|
||||
request.onblocked = () => resolve("BLOCKED");
|
||||
});
|
||||
let opfsCleanup = "ABSENT";
|
||||
try {
|
||||
const originRoot = await navigator.storage.getDirectory();
|
||||
const runtimeRoot = await originRoot.getDirectoryHandle(
|
||||
DEFAULT_OPFS_RUNTIME_POLICY.rootDirectoryName,
|
||||
);
|
||||
const authorities = await runtimeRoot.getDirectoryHandle(
|
||||
"authorities",
|
||||
);
|
||||
await authorities.removeEntry(scope.authorityToken, {
|
||||
recursive: true,
|
||||
});
|
||||
opfsCleanup = "DELETED";
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException) || error.name !== "NotFoundError") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return {
|
||||
nativeSupport: true as const,
|
||||
capabilities,
|
||||
put,
|
||||
opened:
|
||||
opened &&
|
||||
typeof opened === "object" &&
|
||||
"ok" in opened &&
|
||||
opened.ok
|
||||
? {
|
||||
ok: true,
|
||||
generation: opened.value.descriptor.generation,
|
||||
}
|
||||
: opened,
|
||||
readBytes,
|
||||
removed,
|
||||
afterRemove,
|
||||
reconciled,
|
||||
deletion,
|
||||
opfsCleanup,
|
||||
};
|
||||
});
|
||||
|
||||
if (!result.nativeSupport) {
|
||||
expect(result.capabilities).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNSUPPORTED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
expect(result.capabilities).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
available: true,
|
||||
dedicatedWorkerRequired: true,
|
||||
crossContextMutationLockAvailable: true,
|
||||
},
|
||||
});
|
||||
expect(result.put).toMatchObject({
|
||||
ok: true,
|
||||
value: { generation: 1, byteLength: 6 },
|
||||
});
|
||||
expect(result.opened).toEqual({ ok: true, generation: 1 });
|
||||
expect(result.readBytes).toEqual([3, 1, 4, 1, 5, 9]);
|
||||
expect(result.removed).toEqual({ ok: true });
|
||||
expect(result.afterRemove).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "NOT_FOUND" },
|
||||
});
|
||||
expect(result.reconciled).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
inspectedTransactions: 0,
|
||||
orphanGcStatus: "COMPLETED",
|
||||
},
|
||||
});
|
||||
expect(result.deletion).toBe("DELETED");
|
||||
expect(["DELETED", "ABSENT"]).toContain(result.opfsCleanup);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
startBrowserOpfsDedicatedWorker,
|
||||
type OpfsWorkerMessageHost,
|
||||
} from "../../src/adapters/storage/opfs/opfs-worker-runtime.ts";
|
||||
|
||||
void startBrowserOpfsDedicatedWorker(
|
||||
self as unknown as OpfsWorkerMessageHost,
|
||||
{
|
||||
storageManager: navigator.storage,
|
||||
lockManager: navigator.locks,
|
||||
crypto,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,366 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
const RESOURCE_ID = "browser-artifact-1";
|
||||
const OBJECT_ORIGIN = "https://objects.example.test";
|
||||
const OBJECT_PATH = "/files/browser-artifact-1";
|
||||
const OBJECT_HREF =
|
||||
`${OBJECT_ORIGIN}${OBJECT_PATH}?sig=opaque-browser-signature`;
|
||||
const DOWNLOAD_BYTES = Buffer.from([
|
||||
0x10, 0x20, 0x30, 0x40, 0x50,
|
||||
0x60, 0x70, 0x80, 0x90, 0xa0,
|
||||
]);
|
||||
const DOWNLOAD_SHA256 = createHash("sha256")
|
||||
.update(DOWNLOAD_BYTES)
|
||||
.digest("hex");
|
||||
|
||||
test("issues an opaque capability and streams a verified object into the writable save path", async ({
|
||||
page,
|
||||
}) => {
|
||||
const bffRequests: unknown[] = [];
|
||||
const objectRequests: Readonly<{
|
||||
url: string;
|
||||
method: string;
|
||||
accept: string | undefined;
|
||||
cookie: string | undefined;
|
||||
}>[] = [];
|
||||
|
||||
await page.route("**/api/download-capability", async (route) => {
|
||||
bffRequests.push(route.request().postDataJSON());
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
capabilityReceipt: "browser-download-capability-1",
|
||||
method: "GET",
|
||||
binding: {
|
||||
kind: "DOWNLOAD",
|
||||
resourceId: RESOURCE_ID,
|
||||
},
|
||||
href: OBJECT_HREF,
|
||||
origin: OBJECT_ORIGIN,
|
||||
path: OBJECT_PATH,
|
||||
allowedQueryParameters: ["sig"],
|
||||
requestHeaders: [
|
||||
{
|
||||
name: "accept",
|
||||
value: "application/octet-stream",
|
||||
},
|
||||
],
|
||||
requiredResponseHeaders: [
|
||||
{
|
||||
name: "x-policy-version",
|
||||
value: "v1",
|
||||
},
|
||||
],
|
||||
digestRequestHeader: null,
|
||||
digestResponseHeader: "x-content-sha256",
|
||||
receiptResponseHeader: null,
|
||||
expectedStatus: 200,
|
||||
expectedResponseByteLength: null,
|
||||
mediaType: "application/octet-stream",
|
||||
byteLength: DOWNLOAD_BYTES.byteLength,
|
||||
maxBytes: 64,
|
||||
expectedSha256: DOWNLOAD_SHA256,
|
||||
expiresAtEpochMs: Date.now() + 5 * 60_000,
|
||||
singleUse: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(`${OBJECT_ORIGIN}/**`, async (route) => {
|
||||
const headers = route.request().headers();
|
||||
objectRequests.push({
|
||||
url: route.request().url(),
|
||||
method: route.request().method(),
|
||||
accept: headers.accept,
|
||||
cookie: headers.cookie,
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: DOWNLOAD_BYTES,
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-expose-headers":
|
||||
"content-length, x-content-sha256, x-policy-version",
|
||||
"content-length": String(DOWNLOAD_BYTES.byteLength),
|
||||
"content-type": "application/octet-stream",
|
||||
"x-content-sha256": DOWNLOAD_SHA256,
|
||||
"x-policy-version": "v1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(
|
||||
async ({ resourceId }) => {
|
||||
const vaultModulePath =
|
||||
"/src/adapters/browser-transfer/presigned/presigned-capability-vault.ts";
|
||||
const providerModulePath =
|
||||
"/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts";
|
||||
const executorModulePath =
|
||||
"/src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts";
|
||||
const policyModulePath =
|
||||
"/src/adapters/browser-files/browser-file-policy-registry.ts";
|
||||
const deliveryModulePath =
|
||||
"/src/adapters/browser-files/download-delivery-adapter.ts";
|
||||
const [
|
||||
{
|
||||
createPresignedCapabilityVault,
|
||||
createSingleUsePresignedReplayGuard,
|
||||
},
|
||||
{ createPresignedCapabilityHttpProvider },
|
||||
{ createPresignedTransferExecutor },
|
||||
{
|
||||
BrowserFilePolicyRegistry,
|
||||
browserFilePolicyReference,
|
||||
},
|
||||
{ createDownloadDeliveryAdapter },
|
||||
] = await Promise.all([
|
||||
import(
|
||||
/* @vite-ignore */ vaultModulePath
|
||||
) as Promise<
|
||||
typeof import("../../src/adapters/browser-transfer/presigned/presigned-capability-vault.ts")
|
||||
>,
|
||||
import(
|
||||
/* @vite-ignore */ providerModulePath
|
||||
) as Promise<
|
||||
typeof import("../../src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts")
|
||||
>,
|
||||
import(
|
||||
/* @vite-ignore */ executorModulePath
|
||||
) as Promise<
|
||||
typeof import("../../src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts")
|
||||
>,
|
||||
import(
|
||||
/* @vite-ignore */ policyModulePath
|
||||
) as Promise<
|
||||
typeof import("../../src/adapters/browser-files/browser-file-policy-registry.ts")
|
||||
>,
|
||||
import(
|
||||
/* @vite-ignore */ deliveryModulePath
|
||||
) as Promise<
|
||||
typeof import("../../src/adapters/browser-files/download-delivery-adapter.ts")
|
||||
>,
|
||||
]);
|
||||
|
||||
const vault = createPresignedCapabilityVault({
|
||||
maxActiveCapabilities: 4,
|
||||
});
|
||||
const provider = createPresignedCapabilityHttpProvider({
|
||||
endpoint: `${location.origin}/api/download-capability`,
|
||||
vault,
|
||||
allowedDataOrigins: ["https://objects.example.test"],
|
||||
allowedDataPathPrefixes: ["/files/"],
|
||||
allowedQueryParameters: ["sig"],
|
||||
allowedRequestHeaders: ["accept"],
|
||||
allowedResponseHeaders: [
|
||||
"x-content-sha256",
|
||||
"x-policy-version",
|
||||
],
|
||||
hardMaxTransferBytes: 64,
|
||||
hardMaxUploadResponseBytes: 1_024,
|
||||
maxCapabilityTtlMs: 10 * 60_000,
|
||||
minimumRemainingLifetimeMs: 30_000,
|
||||
timeoutMs: 5_000,
|
||||
allowInsecureLocalhost: true,
|
||||
});
|
||||
const executor = createPresignedTransferExecutor({
|
||||
vault,
|
||||
replayGuard: createSingleUsePresignedReplayGuard(),
|
||||
hardMaxTransferBytes: 64,
|
||||
hardMaxChunkBytes: 3,
|
||||
hardMaxUploadResponseBytes: 1_024,
|
||||
minimumRemainingLifetimeMs: 30_000,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
const issued = await provider.issueDownload({
|
||||
resourceId,
|
||||
signal,
|
||||
});
|
||||
if (!issued.ok) {
|
||||
vault.dispose();
|
||||
return {
|
||||
issued,
|
||||
delivery: null,
|
||||
capabilityKeys: [],
|
||||
capabilityBindingKeys: [],
|
||||
written: [],
|
||||
writeChunkSizes: [],
|
||||
writableClosed: false,
|
||||
writableAborted: false,
|
||||
progressPhases: [],
|
||||
};
|
||||
}
|
||||
|
||||
const downloadPolicy = browserFilePolicyReference(
|
||||
"browser-presigned-download",
|
||||
"save-verified-artifact",
|
||||
);
|
||||
const policies = new BrowserFilePolicyRegistry({
|
||||
profiles: [
|
||||
{
|
||||
reference: downloadPolicy,
|
||||
download: {
|
||||
strategy: "PROMPT_AND_STREAM",
|
||||
mediaType: "application/octet-stream",
|
||||
safeExtension: ".bin",
|
||||
maxTransferBytes: 64,
|
||||
maxBufferedBytes: 8,
|
||||
integrity: "REQUIRED",
|
||||
},
|
||||
},
|
||||
],
|
||||
hardLimits: {
|
||||
maxInspectionBytes: 64,
|
||||
maxRetainedFileBytes: 64,
|
||||
maxPreviewBytes: 64,
|
||||
maxObjectUrlBytes: 64,
|
||||
maxTransferBytes: 64,
|
||||
},
|
||||
});
|
||||
const written: number[] = [];
|
||||
const writeChunkSizes: number[] = [];
|
||||
let writableClosed = false;
|
||||
let writableAborted = false;
|
||||
const downloads = createDownloadDeliveryAdapter({
|
||||
host: {
|
||||
handoff() {
|
||||
throw new TypeError(
|
||||
"Writable delivery must not use browser handoff.",
|
||||
);
|
||||
},
|
||||
},
|
||||
policies,
|
||||
hardMaxObjectUrlBytes: 64,
|
||||
hardMaxTransferBytes: 64,
|
||||
browserManagedCapabilities: {
|
||||
resolve() {
|
||||
throw new TypeError(
|
||||
"Browser-managed capability is not used.",
|
||||
);
|
||||
},
|
||||
},
|
||||
openAuthorizedSource:
|
||||
executor.downloadSources.open.bind(
|
||||
executor.downloadSources,
|
||||
),
|
||||
showSaveFilePicker: async () => ({
|
||||
async createWritable() {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
writeChunkSizes.push(chunk.byteLength);
|
||||
written.push(...chunk);
|
||||
},
|
||||
close() {
|
||||
writableClosed = true;
|
||||
},
|
||||
abort() {
|
||||
writableAborted = true;
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
createTransferId: () => "transfer:browser-presigned",
|
||||
progressMinIntervalMs: 0,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
const progressPhases: string[] = [];
|
||||
const capabilityKeys = Object.keys(issued.value).sort();
|
||||
const capabilityBindingKeys = Object.keys(
|
||||
issued.value.binding,
|
||||
).sort();
|
||||
const delivery = await downloads.deliver({
|
||||
policy: downloadPolicy,
|
||||
source: {
|
||||
kind: "AUTHORIZED_STREAM_RESOURCE",
|
||||
resourceId,
|
||||
capability: issued.value,
|
||||
},
|
||||
suggestedFileName: "browser-artifact.bin",
|
||||
signal,
|
||||
onProgress(progress) {
|
||||
progressPhases.push(progress.phase);
|
||||
},
|
||||
});
|
||||
downloads.dispose();
|
||||
vault.dispose();
|
||||
return {
|
||||
issued: {
|
||||
ok: true as const,
|
||||
exposedHref: "href" in issued.value,
|
||||
exposedRequestHeaders: "requestHeaders" in issued.value,
|
||||
},
|
||||
delivery,
|
||||
capabilityKeys,
|
||||
capabilityBindingKeys,
|
||||
written,
|
||||
writeChunkSizes,
|
||||
writableClosed,
|
||||
writableAborted,
|
||||
progressPhases,
|
||||
};
|
||||
},
|
||||
{ resourceId: RESOURCE_ID },
|
||||
);
|
||||
|
||||
expect(bffRequests).toEqual([
|
||||
{
|
||||
method: "GET",
|
||||
binding: {
|
||||
kind: "DOWNLOAD",
|
||||
resourceId: RESOURCE_ID,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(objectRequests).toEqual([
|
||||
{
|
||||
url: OBJECT_HREF,
|
||||
method: "GET",
|
||||
accept: "application/octet-stream",
|
||||
cookie: undefined,
|
||||
},
|
||||
]);
|
||||
expect(result.issued).toEqual({
|
||||
ok: true,
|
||||
exposedHref: false,
|
||||
exposedRequestHeaders: false,
|
||||
});
|
||||
expect(result.capabilityKeys).toEqual([
|
||||
"binding",
|
||||
"byteLength",
|
||||
"capabilityReceipt",
|
||||
"expectedSha256",
|
||||
"expiresAtEpochMs",
|
||||
"maxBytes",
|
||||
"mediaType",
|
||||
"method",
|
||||
]);
|
||||
expect(result.capabilityBindingKeys).toEqual([
|
||||
"kind",
|
||||
"resourceId",
|
||||
]);
|
||||
expect(result.delivery).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "SAVED",
|
||||
transferId: "transfer:browser-presigned",
|
||||
bytesWritten: DOWNLOAD_BYTES.byteLength,
|
||||
integrity: "VERIFIED",
|
||||
},
|
||||
});
|
||||
expect(result.written).toEqual([...DOWNLOAD_BYTES]);
|
||||
expect(result.writeChunkSizes).toEqual([3, 3, 3, 1]);
|
||||
expect(result.writableClosed).toBe(true);
|
||||
expect(result.writableAborted).toBe(false);
|
||||
expect(result.progressPhases).toEqual([
|
||||
"PREPARING",
|
||||
"TRANSFERRING",
|
||||
"TRANSFERRING",
|
||||
"TRANSFERRING",
|
||||
"TRANSFERRING",
|
||||
"VERIFYING",
|
||||
"FINALIZING",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("keeps A active across a failed B stage, activates B with A rollback, and cleans only owned stale caches", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const adapterModulePath =
|
||||
"/src/adapters/cache-storage/public-response-cache-adapter.ts";
|
||||
const policyModulePath =
|
||||
"/src/adapters/cache-storage/public-cache-policy.ts";
|
||||
const {
|
||||
computePublicCacheManifestDigestHex,
|
||||
createPublicCacheWebLock,
|
||||
createPublicResponseCacheAdapter,
|
||||
} = (await import(
|
||||
/* @vite-ignore */ adapterModulePath
|
||||
)) as typeof import("../../src/adapters/cache-storage/public-response-cache-adapter.ts");
|
||||
const { createDefaultPublicCachePolicy } = (await import(
|
||||
/* @vite-ignore */ policyModulePath
|
||||
)) as typeof import("../../src/adapters/cache-storage/public-cache-policy.ts");
|
||||
|
||||
const suffix = crypto.randomUUID().replaceAll("-", "");
|
||||
const releaseAId = `browser-a-${suffix.slice(0, 20)}`;
|
||||
const releaseBId = `browser-b-${suffix.slice(0, 20)}`;
|
||||
const unrelatedName = `unrelated-${suffix}`;
|
||||
const policy = {
|
||||
...createDefaultPublicCachePolicy(location.origin),
|
||||
ownedCachePrefix: `cap-${suffix.slice(0, 24)}:`,
|
||||
mutationLockName: `cap-${suffix}:mutation`,
|
||||
};
|
||||
const payloads = {
|
||||
[`${location.origin}/cache-${suffix}-a.js`]: {
|
||||
bytes: new TextEncoder().encode("verified-release-a"),
|
||||
contentType: "application/javascript",
|
||||
},
|
||||
[`${location.origin}/cache-${suffix}-b.js`]: {
|
||||
bytes: new TextEncoder().encode("verified-release-b"),
|
||||
contentType: "application/javascript",
|
||||
},
|
||||
[`${location.origin}/cache-${suffix}-b.css`]: {
|
||||
bytes: new TextEncoder().encode(".release-b{display:block}"),
|
||||
contentType: "text/css",
|
||||
},
|
||||
} as const;
|
||||
const [aUrl, bUrl, bSecondUrl] = Object.keys(payloads);
|
||||
|
||||
const digestHex = async (bytes: Uint8Array): Promise<string> => {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
Uint8Array.from(bytes),
|
||||
);
|
||||
return Array.from(
|
||||
new Uint8Array(digest),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
};
|
||||
const asset = async (absoluteUrl: string) => {
|
||||
const payload = payloads[absoluteUrl as keyof typeof payloads];
|
||||
return {
|
||||
absoluteUrl,
|
||||
expectedByteLength: payload.bytes.byteLength,
|
||||
expectedContentType: payload.contentType,
|
||||
integrity: {
|
||||
algorithm: "SHA-256" as const,
|
||||
digestHex: await digestHex(payload.bytes),
|
||||
},
|
||||
};
|
||||
};
|
||||
const assetsA = [await asset(aUrl!)];
|
||||
const assetsB = [
|
||||
await asset(bUrl!),
|
||||
await asset(bSecondUrl!),
|
||||
];
|
||||
const manifestA = {
|
||||
releaseRegistryId: releaseAId,
|
||||
assets: assetsA,
|
||||
manifestDigestHex:
|
||||
await computePublicCacheManifestDigestHex(
|
||||
crypto,
|
||||
releaseAId,
|
||||
assetsA,
|
||||
policy,
|
||||
),
|
||||
};
|
||||
const manifestB = {
|
||||
releaseRegistryId: releaseBId,
|
||||
assets: assetsB,
|
||||
manifestDigestHex:
|
||||
await computePublicCacheManifestDigestHex(
|
||||
crypto,
|
||||
releaseBId,
|
||||
assetsB,
|
||||
policy,
|
||||
),
|
||||
};
|
||||
let corruptSecondBAsset = true;
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: caches,
|
||||
crypto,
|
||||
mutationLock: createPublicCacheWebLock(
|
||||
navigator.locks,
|
||||
policy.mutationLockName,
|
||||
),
|
||||
policy,
|
||||
fetcher: async (request) => {
|
||||
if (request.signal.aborted) {
|
||||
throw new DOMException("Aborted.", "AbortError");
|
||||
}
|
||||
const payload =
|
||||
payloads[request.url as keyof typeof payloads];
|
||||
if (!payload) throw new TypeError("Unexpected cache URL.");
|
||||
const responseBytes =
|
||||
corruptSecondBAsset && request.url === bSecondUrl
|
||||
? new Uint8Array(payload.bytes.byteLength)
|
||||
: Uint8Array.from(payload.bytes);
|
||||
return new Response(responseBytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control":
|
||||
"public, max-age=31536000, immutable",
|
||||
"content-type": payload.contentType,
|
||||
"x-account-id": "must-not-persist",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const readActive = async (absoluteUrl: string) => {
|
||||
const matched = await adapter.responses.matchActiveExact({
|
||||
absoluteUrl,
|
||||
});
|
||||
if (!matched.ok || !matched.value) return matched;
|
||||
const streamed: number[] = [];
|
||||
for await (const chunk of matched.value.body.stream(
|
||||
new AbortController().signal,
|
||||
)) {
|
||||
if (!chunk.ok) return chunk;
|
||||
streamed.push(...chunk.value);
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
streamed,
|
||||
headerNames: matched.value.headers.map(([name]) => name),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const stageA = await adapter.admin.stageRelease(manifestA);
|
||||
const activateA = await adapter.admin.activateRelease(
|
||||
manifestA.releaseRegistryId,
|
||||
manifestA.manifestDigestHex,
|
||||
);
|
||||
const matchedABeforeFailedB = await readActive(aUrl!);
|
||||
|
||||
const failedStageB =
|
||||
await adapter.admin.stageRelease(manifestB);
|
||||
const afterFailedB = await adapter.admin.inspect();
|
||||
const matchedAAfterFailedB = await readActive(aUrl!);
|
||||
|
||||
corruptSecondBAsset = false;
|
||||
const stageB = await adapter.admin.stageRelease(manifestB);
|
||||
const activateB = await adapter.admin.activateRelease(
|
||||
manifestB.releaseRegistryId,
|
||||
manifestB.manifestDigestHex,
|
||||
);
|
||||
const matchedB = await readActive(bUrl!);
|
||||
const afterActivateB = await adapter.admin.inspect();
|
||||
|
||||
const releaseBCacheName = (await caches.keys()).find((name) =>
|
||||
name.startsWith(
|
||||
`${policy.ownedCachePrefix}release:${releaseBId}:`,
|
||||
),
|
||||
);
|
||||
if (!releaseBCacheName) {
|
||||
throw new Error("B release cache not found.");
|
||||
}
|
||||
const releaseBCache = await caches.open(releaseBCacheName);
|
||||
await releaseBCache.put(
|
||||
new Request(bUrl!),
|
||||
new Response(new Uint8Array([0]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control": "public",
|
||||
"content-type": "application/javascript",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const poisoned = await adapter.responses.matchActiveExact({
|
||||
absoluteUrl: bUrl!,
|
||||
});
|
||||
|
||||
await caches.open(`${policy.ownedCachePrefix}stale`);
|
||||
await caches.open(unrelatedName);
|
||||
const cleanup = await adapter.admin.cleanupOwned();
|
||||
const names = await caches.keys();
|
||||
return {
|
||||
stageA,
|
||||
activateA,
|
||||
matchedABeforeFailedB,
|
||||
failedStageB,
|
||||
afterFailedB,
|
||||
matchedAAfterFailedB,
|
||||
stageB,
|
||||
activateB,
|
||||
matchedB,
|
||||
afterActivateB,
|
||||
poisoned,
|
||||
cleanup,
|
||||
unrelatedPreserved: names.includes(unrelatedName),
|
||||
activeBPreserved: names.some((name) =>
|
||||
name.startsWith(
|
||||
`${policy.ownedCachePrefix}release:${releaseBId}:`,
|
||||
),
|
||||
),
|
||||
rollbackAPreserved: names.some((name) =>
|
||||
name.startsWith(
|
||||
`${policy.ownedCachePrefix}release:${releaseAId}:`,
|
||||
),
|
||||
),
|
||||
remainingOwnedCount: names.filter((name) =>
|
||||
name.startsWith(policy.ownedCachePrefix),
|
||||
).length,
|
||||
expectedABytes: [...payloads[aUrl! as keyof typeof payloads].bytes],
|
||||
expectedBBytes: [...payloads[bUrl! as keyof typeof payloads].bytes],
|
||||
};
|
||||
} finally {
|
||||
for (const name of await caches.keys()) {
|
||||
if (
|
||||
name.startsWith(policy.ownedCachePrefix) ||
|
||||
name === unrelatedName
|
||||
) {
|
||||
await caches.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.stageA).toMatchObject({
|
||||
ok: true,
|
||||
value: { entryCount: 1 },
|
||||
});
|
||||
expect(result.activateA).toMatchObject({ ok: true });
|
||||
expect(result.matchedABeforeFailedB).toMatchObject({
|
||||
ok: true,
|
||||
streamed: result.expectedABytes,
|
||||
});
|
||||
expect(result.failedStageB).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INTEGRITY_FAILED" },
|
||||
});
|
||||
expect(result.afterFailedB).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
activeReleaseRegistryId: expect.stringMatching(/^browser-a-/u),
|
||||
releaseCandidates: [
|
||||
expect.objectContaining({
|
||||
releaseRegistryId: expect.stringMatching(/^browser-a-/u),
|
||||
verified: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.matchedAAfterFailedB).toMatchObject({
|
||||
ok: true,
|
||||
streamed: result.expectedABytes,
|
||||
});
|
||||
expect(result.stageB).toMatchObject({
|
||||
ok: true,
|
||||
value: { entryCount: 2 },
|
||||
});
|
||||
expect(result.activateB).toMatchObject({ ok: true });
|
||||
expect(result.matchedB).toMatchObject({
|
||||
ok: true,
|
||||
streamed: result.expectedBBytes,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
result.matchedB as Readonly<{
|
||||
headerNames: readonly string[];
|
||||
}>
|
||||
).headerNames,
|
||||
).not.toContain("x-account-id");
|
||||
expect(result.afterActivateB).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
activeReleaseRegistryId: expect.stringMatching(/^browser-b-/u),
|
||||
releaseCandidates: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
releaseRegistryId: expect.stringMatching(/^browser-a-/u),
|
||||
verified: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
releaseRegistryId: expect.stringMatching(/^browser-b-/u),
|
||||
verified: true,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(result.poisoned).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INTEGRITY_FAILED" },
|
||||
});
|
||||
expect(result.cleanup).toMatchObject({
|
||||
ok: true,
|
||||
value: { deletedOwnedCaches: 1, retainedOwnedCaches: 3 },
|
||||
});
|
||||
expect(result.unrelatedPreserved).toBe(true);
|
||||
expect(result.activeBPreserved).toBe(true);
|
||||
expect(result.rollbackAPreserved).toBe(true);
|
||||
expect(result.remainingOwnedCount).toBe(3);
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { Route } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
const PAGE_ORIGIN = "http://127.0.0.1:4174";
|
||||
const API_ORIGIN = "https://upload-api.example.test";
|
||||
const OBJECT_ORIGIN = "https://upload-objects.example.test";
|
||||
const SESSION_ID = "session_browser_01";
|
||||
const RESOURCE_ID = "resource_browser_01";
|
||||
const UPLOAD_PROTOCOL = "PRESIGNED_MULTIPART_V1";
|
||||
|
||||
type JsonRecord = Readonly<Record<string, unknown>>;
|
||||
|
||||
function corsHeaders(): Record<string, string> {
|
||||
return {
|
||||
"access-control-allow-credentials": "true",
|
||||
"access-control-allow-headers": "content-type",
|
||||
"access-control-allow-methods": "POST, OPTIONS",
|
||||
"access-control-allow-origin": PAGE_ORIGIN,
|
||||
};
|
||||
}
|
||||
|
||||
async function fulfillJson(
|
||||
route: Route,
|
||||
status: number,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const body = JSON.stringify(value);
|
||||
await route.fulfill({
|
||||
status,
|
||||
body,
|
||||
headers: {
|
||||
...corsHeaders(),
|
||||
"content-length": String(Buffer.byteLength(body)),
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("uploads three presigned parts through native IndexedDB, Web Locks and fetch", async ({
|
||||
page,
|
||||
}) => {
|
||||
let session: JsonRecord | null = null;
|
||||
const acceptedParts = new Map<number, JsonRecord>();
|
||||
const issuedParts = new Map<number, JsonRecord>();
|
||||
const controlRequests: Readonly<{
|
||||
path: string;
|
||||
body: JsonRecord;
|
||||
}>[] = [];
|
||||
const uploadedBodies: Readonly<{
|
||||
partNumber: number;
|
||||
bytes: number[];
|
||||
checksum: string | undefined;
|
||||
}>[] = [];
|
||||
let completedParts: readonly JsonRecord[] = [];
|
||||
|
||||
await page.route(`${API_ORIGIN}/**`, async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({ status: 204, headers: corsHeaders() });
|
||||
return;
|
||||
}
|
||||
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
const body = route.request().postDataJSON() as JsonRecord;
|
||||
controlRequests.push({ path, body });
|
||||
|
||||
if (path === "/uploads/create") {
|
||||
const fingerprint = body.fingerprint as JsonRecord;
|
||||
session = {
|
||||
protocol: UPLOAD_PROTOCOL,
|
||||
sessionId: SESSION_ID,
|
||||
requestBindingSha256: body.requestBindingSha256,
|
||||
fingerprint,
|
||||
partSizeBytes: body.requestedPartSizeBytes,
|
||||
partCount: fingerprint.partCount,
|
||||
maxConcurrency: 1,
|
||||
expiresAtEpochMs: Date.now() + 5 * 60_000,
|
||||
};
|
||||
await fulfillJson(route, 201, session);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/uploads/status") {
|
||||
if (!session) {
|
||||
await fulfillJson(route, 404, { state: "NOT_FOUND" });
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, 200, {
|
||||
state: "ACTIVE",
|
||||
session,
|
||||
acceptedParts: [...acceptedParts.values()].sort(
|
||||
(left, right) =>
|
||||
Number(left.partNumber) - Number(right.partNumber),
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/uploads/complete") {
|
||||
completedParts = body.orderedParts as readonly JsonRecord[];
|
||||
await fulfillJson(route, 200, {
|
||||
state: "QUARANTINED",
|
||||
protocol: UPLOAD_PROTOCOL,
|
||||
sessionId: body.sessionId,
|
||||
requestBindingSha256: body.requestBindingSha256,
|
||||
fingerprint: body.fingerprint,
|
||||
resourceId: RESOURCE_ID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/uploads/abort") {
|
||||
await fulfillJson(route, 200, { state: "ABORTED" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/uploads/part-capability") {
|
||||
const binding = body.binding as JsonRecord;
|
||||
const partNumber = Number(binding.partNumber);
|
||||
const descriptor = {
|
||||
partNumber,
|
||||
offset: binding.offset,
|
||||
byteLength: body.byteLength,
|
||||
checksumSha256: body.expectedSha256,
|
||||
};
|
||||
issuedParts.set(partNumber, descriptor);
|
||||
const objectPath =
|
||||
`/uploads/${SESSION_ID}/parts/${String(partNumber)}`;
|
||||
await fulfillJson(route, 200, {
|
||||
capabilityReceipt: `browser-part-capability-${String(partNumber)}`,
|
||||
method: "PUT",
|
||||
binding,
|
||||
href:
|
||||
`${OBJECT_ORIGIN}${objectPath}?sig=opaque-${String(partNumber)}`,
|
||||
origin: OBJECT_ORIGIN,
|
||||
path: objectPath,
|
||||
allowedQueryParameters: ["sig"],
|
||||
requestHeaders: [
|
||||
{
|
||||
name: "content-type",
|
||||
value: body.mediaType,
|
||||
},
|
||||
{
|
||||
name: "x-checksum-sha256",
|
||||
value: body.expectedSha256,
|
||||
},
|
||||
],
|
||||
requiredResponseHeaders: [
|
||||
{
|
||||
name: "x-policy-version",
|
||||
value: "v1",
|
||||
},
|
||||
],
|
||||
digestRequestHeader: "x-checksum-sha256",
|
||||
digestResponseHeader: null,
|
||||
receiptResponseHeader: "etag",
|
||||
expectedResponseByteLength: 0,
|
||||
expectedStatus: 204,
|
||||
mediaType: body.mediaType,
|
||||
byteLength: body.byteLength,
|
||||
maxBytes: body.byteLength,
|
||||
expectedSha256: body.expectedSha256,
|
||||
expiresAtEpochMs:
|
||||
Number(session?.expiresAtEpochMs) - 10_000,
|
||||
singleUse: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 404, headers: corsHeaders() });
|
||||
});
|
||||
|
||||
await page.route(`${OBJECT_ORIGIN}/**`, async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: {
|
||||
"access-control-allow-headers":
|
||||
"content-type, x-checksum-sha256",
|
||||
"access-control-allow-methods": "PUT, OPTIONS",
|
||||
"access-control-allow-origin": "*",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const match = new URL(route.request().url()).pathname.match(
|
||||
/\/parts\/([1-9][0-9]*)$/u,
|
||||
);
|
||||
const partNumber = Number(match?.[1]);
|
||||
const body = route.request().postDataBuffer();
|
||||
if (!body || !Number.isSafeInteger(partNumber)) {
|
||||
await route.fulfill({ status: 400 });
|
||||
return;
|
||||
}
|
||||
const checksum = route.request().headers()["x-checksum-sha256"];
|
||||
const actualChecksum = createHash("sha256")
|
||||
.update(body)
|
||||
.digest("hex");
|
||||
const descriptor = issuedParts.get(partNumber);
|
||||
if (
|
||||
!descriptor ||
|
||||
checksum !== actualChecksum ||
|
||||
descriptor.checksumSha256 !== actualChecksum ||
|
||||
descriptor.byteLength !== body.byteLength
|
||||
) {
|
||||
await route.fulfill({ status: 422 });
|
||||
return;
|
||||
}
|
||||
const receiptToken = `etag-part-${String(partNumber)}`;
|
||||
acceptedParts.set(partNumber, {
|
||||
...descriptor,
|
||||
receiptToken,
|
||||
});
|
||||
uploadedBodies.push({
|
||||
partNumber,
|
||||
bytes: [...body],
|
||||
checksum,
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-expose-headers": "etag, x-policy-version",
|
||||
etag: `"${receiptToken}"`,
|
||||
"x-policy-version": "v1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(
|
||||
async ({ apiOrigin, objectOrigin }) => {
|
||||
const vaultModulePath =
|
||||
"/src/adapters/browser-transfer/presigned/presigned-capability-vault.ts";
|
||||
const providerModulePath =
|
||||
"/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts";
|
||||
const executorModulePath =
|
||||
"/src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts";
|
||||
const fetchTransportModulePath =
|
||||
"/src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts";
|
||||
const controlPlaneModulePath =
|
||||
"/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts";
|
||||
const checkpointModulePath =
|
||||
"/src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts";
|
||||
const partExecutorModulePath =
|
||||
"/src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts";
|
||||
const uploadRuntimeModulePath =
|
||||
"/src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts";
|
||||
const cancellationModulePath =
|
||||
"/src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
|
||||
const lockModulePath =
|
||||
"/src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
|
||||
const [
|
||||
{
|
||||
createPresignedCapabilityVault,
|
||||
createSingleUsePresignedReplayGuard,
|
||||
},
|
||||
{ createPresignedCapabilityHttpProvider },
|
||||
{ createPresignedTransferExecutor },
|
||||
{ createResumableUploadFetchJsonTransport },
|
||||
{ createResumableUploadHttpControlPlane },
|
||||
{ createIndexedDbResumableUploadCheckpointRuntime },
|
||||
{ createPresignedUploadPartExecutor },
|
||||
{ createResumableUploadRuntime },
|
||||
{ createBrowserUploadCancellationChannel },
|
||||
{ createResumableUploadWebLock },
|
||||
] = await Promise.all([
|
||||
import(/* @vite-ignore */ vaultModulePath),
|
||||
import(/* @vite-ignore */ providerModulePath),
|
||||
import(/* @vite-ignore */ executorModulePath),
|
||||
import(/* @vite-ignore */ fetchTransportModulePath),
|
||||
import(/* @vite-ignore */ controlPlaneModulePath),
|
||||
import(/* @vite-ignore */ checkpointModulePath),
|
||||
import(/* @vite-ignore */ partExecutorModulePath),
|
||||
import(/* @vite-ignore */ uploadRuntimeModulePath),
|
||||
import(/* @vite-ignore */ cancellationModulePath),
|
||||
import(/* @vite-ignore */ lockModulePath),
|
||||
]);
|
||||
|
||||
const suffix = crypto.randomUUID().replaceAll("-", "");
|
||||
const uploadKey = `upload_browser_${suffix}`;
|
||||
const checkpointRuntime =
|
||||
createIndexedDbResumableUploadCheckpointRuntime({
|
||||
scope: {
|
||||
authorityToken: `authority_${suffix}`,
|
||||
namespaceToken: `namespace_${suffix}`,
|
||||
partitionToken: `partition_${suffix}`,
|
||||
},
|
||||
blockedTimeoutMs: 2_000,
|
||||
});
|
||||
const vault = createPresignedCapabilityVault({
|
||||
maxActiveCapabilities: 8,
|
||||
});
|
||||
const capabilityProvider =
|
||||
createPresignedCapabilityHttpProvider({
|
||||
endpoint: `${apiOrigin}/uploads/part-capability`,
|
||||
vault,
|
||||
allowedDataOrigins: [objectOrigin],
|
||||
allowedDataPathPrefixes: ["/uploads/"],
|
||||
allowedQueryParameters: ["sig"],
|
||||
allowedRequestHeaders: [
|
||||
"content-type",
|
||||
"x-checksum-sha256",
|
||||
],
|
||||
allowedResponseHeaders: ["etag", "x-policy-version"],
|
||||
hardMaxTransferBytes: 8,
|
||||
hardMaxUploadResponseBytes: 1_024,
|
||||
maxCapabilityTtlMs: 10 * 60_000,
|
||||
minimumRemainingLifetimeMs: 30_000,
|
||||
timeoutMs: 5_000,
|
||||
controlPlaneCredentials: "include",
|
||||
});
|
||||
const transferExecutor = createPresignedTransferExecutor({
|
||||
vault,
|
||||
replayGuard: createSingleUsePresignedReplayGuard(),
|
||||
hardMaxTransferBytes: 8,
|
||||
hardMaxChunkBytes: 2,
|
||||
hardMaxUploadResponseBytes: 1_024,
|
||||
minimumRemainingLifetimeMs: 30_000,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
const transport = createResumableUploadFetchJsonTransport({
|
||||
endpoints: {
|
||||
CREATE_SESSION: `${apiOrigin}/uploads/create`,
|
||||
GET_STATUS: `${apiOrigin}/uploads/status`,
|
||||
COMPLETE: `${apiOrigin}/uploads/complete`,
|
||||
ABORT: `${apiOrigin}/uploads/abort`,
|
||||
},
|
||||
allowedOrigins: [apiOrigin],
|
||||
credentials: "include",
|
||||
timeoutMs: 5_000,
|
||||
maxRequestBytes: 64 * 1024,
|
||||
maxResponseBytes: 64 * 1024,
|
||||
maxRetryAfterMs: 1_000,
|
||||
});
|
||||
const controlPlane = createResumableUploadHttpControlPlane({
|
||||
transport,
|
||||
partCapabilities: capabilityProvider,
|
||||
});
|
||||
const cancellationChannelName =
|
||||
`browser-upload-cancel-${suffix}`;
|
||||
const crossContextCancellation =
|
||||
createBrowserUploadCancellationChannel({
|
||||
channelName: cancellationChannelName,
|
||||
});
|
||||
const cancellationPeer =
|
||||
createBrowserUploadCancellationChannel({
|
||||
channelName: cancellationChannelName,
|
||||
});
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane,
|
||||
partExecutor: createPresignedUploadPartExecutor(
|
||||
transferExecutor.uploadParts,
|
||||
),
|
||||
checkpoints: checkpointRuntime.store,
|
||||
mutationLock: createResumableUploadWebLock(
|
||||
navigator.locks,
|
||||
"browser-resumable-upload",
|
||||
),
|
||||
...(crossContextCancellation
|
||||
? { crossContextCancellation }
|
||||
: {}),
|
||||
crypto,
|
||||
policy: {
|
||||
partSizeBytes: 2,
|
||||
maxFileBytes: 6,
|
||||
maxPartCount: 3,
|
||||
maxConcurrency: 2,
|
||||
maxInFlightBytes: 16,
|
||||
partBufferCopyFactor: 4,
|
||||
maxSourceChunkBytes: 2,
|
||||
maxRetries: 1,
|
||||
retryBaseDelayMs: 1,
|
||||
retryMaxDelayMs: 5,
|
||||
maxRetryAfterMs: 1_000,
|
||||
capabilityRefreshSkewMs: 1_000,
|
||||
maxSessionLifetimeMs: 10 * 60_000,
|
||||
providerAttemptTimeoutMs: 10_000,
|
||||
},
|
||||
});
|
||||
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const progress: string[] = [];
|
||||
let upload: unknown;
|
||||
let checkpoint: unknown;
|
||||
let deletion: unknown;
|
||||
let cancellationDelivered = false;
|
||||
try {
|
||||
if (crossContextCancellation && cancellationPeer) {
|
||||
cancellationDelivered = await new Promise<boolean>(
|
||||
(resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), 2_000);
|
||||
const release = cancellationPeer.subscribe((key: string) => {
|
||||
if (key !== `upload_probe_${suffix}`) return;
|
||||
clearTimeout(timer);
|
||||
release();
|
||||
resolve(true);
|
||||
});
|
||||
if (
|
||||
!crossContextCancellation.publish(
|
||||
`upload_probe_${suffix}`,
|
||||
)
|
||||
) {
|
||||
clearTimeout(timer);
|
||||
release();
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
upload = await runtime.upload({
|
||||
uploadKey,
|
||||
purpose: "browser_attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: {
|
||||
kind: "RANGE_READER",
|
||||
reader: {
|
||||
byteLength: bytes.byteLength,
|
||||
async readRange(input: {
|
||||
offset: number;
|
||||
length: number;
|
||||
signal: AbortSignal;
|
||||
}) {
|
||||
return input.signal.aborted
|
||||
? {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: "ABORTED" as const,
|
||||
operation: "FILE_READ" as const,
|
||||
retryable: false,
|
||||
recovery: "NONE" as const,
|
||||
},
|
||||
}
|
||||
: {
|
||||
ok: true as const,
|
||||
value: bytes.slice(
|
||||
input.offset,
|
||||
input.offset + input.length,
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
onProgress(value: { phase: string }) {
|
||||
progress.push(value.phase);
|
||||
},
|
||||
});
|
||||
checkpoint = await checkpointRuntime.store.read(uploadKey);
|
||||
} finally {
|
||||
runtime.close();
|
||||
cancellationPeer?.close();
|
||||
deletion =
|
||||
await checkpointRuntime.admin.deletePartition();
|
||||
vault.dispose();
|
||||
}
|
||||
return {
|
||||
upload,
|
||||
checkpoint,
|
||||
deletion,
|
||||
progress,
|
||||
webLocksAvailable: Boolean(navigator.locks),
|
||||
broadcastCancellationAvailable:
|
||||
Boolean(crossContextCancellation),
|
||||
cancellationDelivered,
|
||||
};
|
||||
},
|
||||
{ apiOrigin: API_ORIGIN, objectOrigin: OBJECT_ORIGIN },
|
||||
);
|
||||
|
||||
expect(result.webLocksAvailable).toBe(true);
|
||||
expect(result.broadcastCancellationAvailable).toBe(true);
|
||||
expect(result.cancellationDelivered).toBe(true);
|
||||
expect(result.upload).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "QUARANTINED",
|
||||
resourceId: RESOURCE_ID,
|
||||
byteLength: 5,
|
||||
replayed: false,
|
||||
},
|
||||
});
|
||||
expect(result.checkpoint).toEqual({ ok: true, value: null });
|
||||
expect(result.deletion).toEqual({
|
||||
ok: true,
|
||||
value: { state: "DELETED" },
|
||||
});
|
||||
expect(
|
||||
uploadedBodies
|
||||
.slice()
|
||||
.sort((left, right) => left.partNumber - right.partNumber)
|
||||
.map(({ partNumber, bytes }) => ({ partNumber, bytes })),
|
||||
).toEqual([
|
||||
{ partNumber: 1, bytes: [1, 2] },
|
||||
{ partNumber: 2, bytes: [3, 4] },
|
||||
{ partNumber: 3, bytes: [5] },
|
||||
]);
|
||||
expect(completedParts.map((part) => part.partNumber)).toEqual([
|
||||
1, 2, 3,
|
||||
]);
|
||||
expect(completedParts.map((part) => part.receiptToken)).toEqual([
|
||||
"etag-part-1",
|
||||
"etag-part-2",
|
||||
"etag-part-3",
|
||||
]);
|
||||
expect(controlRequests.map((request) => request.path)).toEqual([
|
||||
"/uploads/create",
|
||||
"/uploads/status",
|
||||
"/uploads/part-capability",
|
||||
"/uploads/part-capability",
|
||||
"/uploads/part-capability",
|
||||
"/uploads/status",
|
||||
"/uploads/complete",
|
||||
]);
|
||||
expect(JSON.stringify(controlRequests)).not.toContain("?sig=");
|
||||
expect(JSON.stringify(controlRequests)).not.toContain(OBJECT_ORIGIN);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("executes the storage durability adapter against the real browser StorageManager", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const modulePath =
|
||||
"/src/adapters/browser-file-storage/storage-manager-adapter.ts";
|
||||
const { createStorageDurabilityAdapter } = await import(
|
||||
/* @vite-ignore */ modulePath
|
||||
);
|
||||
const adapter = createStorageDurabilityAdapter(navigator.storage);
|
||||
return adapter.inspect();
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(["UNKNOWN", "NORMAL", "PRESSURE", "CRITICAL"]).toContain(
|
||||
result.value.pressure,
|
||||
);
|
||||
expect(
|
||||
result.value.usageBytes === null || result.value.usageBytes >= 0,
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.value.quotaBytes === null || result.value.quotaBytes >= 0,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { deriveAsyncState } from "../../src/application/view-models/async-state.ts";
|
||||
import { AsyncSurface } from "../../src/presentation/components/async-surface.tsx";
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
|
||||
describe("async UI state matrix", () => {
|
||||
it.each([
|
||||
[{ isInitialLoading: true }, "initial-loading"],
|
||||
[{ data: [{ id: "1" }] }, "success"],
|
||||
[{ data: [] }, "empty"],
|
||||
[
|
||||
{ failure: createFailure("SERVER_FAILURE", "LIST", 0) },
|
||||
"terminal-error",
|
||||
],
|
||||
])("derives base state %#", (signals, expected) => {
|
||||
expect(deriveAsyncState(signals).base).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ data: ["value"], isFetching: true }, "refreshing"],
|
||||
[{ data: ["value"], isStale: true, isDegraded: true }, "stale-degraded"],
|
||||
[{ data: ["value"], isMutationPending: true }, "mutation-pending"],
|
||||
[
|
||||
{ data: ["value"], hasMutationEffectUnknown: true },
|
||||
"mutation-effect-unknown",
|
||||
],
|
||||
[{ data: ["value"], hasMutationConflict: true }, "mutation-conflict"],
|
||||
])("derives overlay state %#", (signals, indicator) => {
|
||||
expect(deriveAsyncState(signals).indicator).toBe(indicator);
|
||||
});
|
||||
|
||||
it("makes crossed overlay inputs mutually exclusive by priority", () => {
|
||||
const state = deriveAsyncState({
|
||||
data: ["value"],
|
||||
isFetching: true,
|
||||
isMutationPending: true,
|
||||
hasMutationEffectUnknown: true,
|
||||
hasMutationConflict: true,
|
||||
});
|
||||
expect(state.indicator).toBe("mutation-effect-unknown");
|
||||
expect(state.overlay).toMatchObject({
|
||||
refreshing: false,
|
||||
mutationPending: false,
|
||||
mutationEffectUnknown: true,
|
||||
mutationConflict: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders unknown mutation effects with reconciliation-only actions", async () => {
|
||||
const user = userEvent.setup();
|
||||
const retry = vi.fn();
|
||||
const reconcile = vi.fn();
|
||||
const state = deriveAsyncState({
|
||||
data: ["value"],
|
||||
hasMutationEffectUnknown: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<AsyncSurface
|
||||
state={state}
|
||||
onRetry={retry}
|
||||
onReconcileUnknownEffect={reconcile}
|
||||
>
|
||||
existing content
|
||||
</AsyncSurface>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"변경 결과를 확인할 수 없습니다.",
|
||||
);
|
||||
expect(
|
||||
screen.getByText("existing content").closest("section"),
|
||||
).toHaveAttribute("aria-busy", "false");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "다시 시도" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "변경됨으로 확인" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "변경되지 않음으로 확인" }),
|
||||
);
|
||||
expect(reconcile).toHaveBeenNthCalledWith(1, "APPLIED");
|
||||
expect(reconcile).toHaveBeenNthCalledWith(2, "NOT_APPLIED");
|
||||
expect(retry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps content visible while a non-blocking refresh runs", () => {
|
||||
const state = deriveAsyncState({ data: ["value"], isFetching: true });
|
||||
render(<AsyncSurface state={state}>existing content</AsyncSurface>);
|
||||
|
||||
expect(screen.getByText("existing content")).toBeVisible();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"최신 정보를 확인하고 있습니다.",
|
||||
);
|
||||
});
|
||||
|
||||
it("connects stale retry and conflict resolution to real callbacks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const retry = vi.fn();
|
||||
const resolveConflict = vi.fn();
|
||||
const stale = deriveAsyncState({
|
||||
data: ["value"],
|
||||
isStale: true,
|
||||
isDegraded: true,
|
||||
});
|
||||
const view = render(
|
||||
<AsyncSurface state={stale} onRetry={retry}>
|
||||
existing content
|
||||
</AsyncSurface>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
|
||||
const conflict = deriveAsyncState({
|
||||
data: ["value"],
|
||||
hasMutationConflict: true,
|
||||
});
|
||||
view.rerender(
|
||||
<AsyncSurface
|
||||
state={conflict}
|
||||
onResolveConflict={resolveConflict}
|
||||
>
|
||||
existing content
|
||||
</AsyncSurface>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "충돌 해결" }));
|
||||
expect(resolveConflict).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders only safe error vocabulary", () => {
|
||||
const failure = createFailure("SERVER_FAILURE", "LIST", 0, {
|
||||
code: "SERVER_FAILURE",
|
||||
});
|
||||
const state = deriveAsyncState({ failure });
|
||||
render(<AsyncSurface state={state} onRetry={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"요청을 완료하지 못했습니다.",
|
||||
);
|
||||
expect(screen.getByRole("alert")).toHaveAttribute(
|
||||
"data-message-key",
|
||||
failure.userMessageKey,
|
||||
);
|
||||
expect(screen.getByRole("button")).toHaveTextContent("다시 시도");
|
||||
expect(screen.getByRole("alert")).not.toHaveTextContent("stack");
|
||||
});
|
||||
|
||||
it("routes terminal actions by failure semantics", async () => {
|
||||
const user = userEvent.setup();
|
||||
const retry = vi.fn();
|
||||
const action = vi.fn();
|
||||
const forbidden = deriveAsyncState({
|
||||
failure: createFailure("FORBIDDEN", "LIST", 0),
|
||||
});
|
||||
const view = render(
|
||||
<AsyncSurface
|
||||
state={forbidden}
|
||||
onAction={action}
|
||||
onRetry={retry}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "안전한 화면으로 이동" }),
|
||||
);
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
expect(retry).not.toHaveBeenCalled();
|
||||
|
||||
const retryable = deriveAsyncState({
|
||||
failure: createFailure("SERVER_FAILURE", "LIST", 0),
|
||||
});
|
||||
view.rerender(
|
||||
<AsyncSurface
|
||||
state={retryable}
|
||||
onAction={action}
|
||||
onRetry={retry}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not retain a terminal error after usable data is restored", () => {
|
||||
const failed = deriveAsyncState({
|
||||
failure: createFailure("SERVER_FAILURE", "LIST", 0),
|
||||
});
|
||||
const recovered = deriveAsyncState({ data: ["value"] });
|
||||
expect(failed.base).toBe("terminal-error");
|
||||
expect(recovered.base).toBe("success");
|
||||
expect(recovered.failure).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function TestShell() {
|
||||
return <main aria-label="application shell">ready</main>;
|
||||
}
|
||||
|
||||
describe("component test level", () => {
|
||||
it("renders an accessible application shell", () => {
|
||||
render(<TestShell />);
|
||||
expect(screen.getByRole("main", { name: "application shell" })).toHaveTextContent(
|
||||
"ready",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SafeText } from "../../src/presentation/security/safe-text.tsx";
|
||||
import { assertSafeConfigNames } from "../../src/contracts/env.ts";
|
||||
import { defineStorageKey } from "../../src/contracts/storage-keys.ts";
|
||||
import { projectTelemetryEvent } from "../../src/contracts/telemetry.ts";
|
||||
|
||||
describe("browser security boundary", () => {
|
||||
it("renders untrusted text without script or inline handler injection", () => {
|
||||
render(
|
||||
<SafeText value={'<img src=x onerror="window.compromised=true"><script>x</script>'} />,
|
||||
);
|
||||
expect(screen.getByText(/<img/)).toBeVisible();
|
||||
expect(document.querySelector("script")).toBeNull();
|
||||
expect(document.querySelector("[onerror]")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects secret-like client configuration names", () => {
|
||||
expect(() => assertSafeConfigNames({ PRIVATE_KEY: "not-public" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects browser token storage registration", () => {
|
||||
expect(() =>
|
||||
defineStorageKey({
|
||||
logicalName: "SESSION_TOKEN",
|
||||
scope: "auth",
|
||||
name: "session-token",
|
||||
backend: "sessionStorage",
|
||||
classification: "sensitive-forbidden",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "none",
|
||||
ttl: "session",
|
||||
migration: "discard",
|
||||
quotaFallback: "feature-disable",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("drops raw URL/query/token telemetry attributes", () => {
|
||||
const result = projectTelemetryEvent("api.request.failed", {
|
||||
error_kind: "SERVER_FAILURE",
|
||||
http_status_group: "5xx",
|
||||
attempt_count_bucket: "1",
|
||||
route_id: "APP_HOME",
|
||||
raw_url: "https://api.test?token=private",
|
||||
query_string: "token=private",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(JSON.stringify(result)).not.toMatch(/raw_url|query_string|private/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ChunkRecoveryBoundary,
|
||||
isChunkLoadFailure,
|
||||
} from "../../src/presentation/boundaries/chunk-recovery-boundary.tsx";
|
||||
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.tsx";
|
||||
|
||||
function ChunkDefect(): never {
|
||||
throw new TypeError("Failed to fetch dynamically imported module");
|
||||
}
|
||||
|
||||
function RenderDefect(): never {
|
||||
throw new Error("ordinary render defect");
|
||||
}
|
||||
|
||||
describe("chunk recovery boundary classification", () => {
|
||||
it("recognizes lazy module failures without classifying ordinary render errors", () => {
|
||||
expect(
|
||||
isChunkLoadFailure(
|
||||
new TypeError("Failed to fetch dynamically imported module"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isChunkLoadFailure(new Error("ordinary render defect"))).toBe(false);
|
||||
});
|
||||
|
||||
it("runs the recovery input only for a lazy chunk rejection", async () => {
|
||||
const recover = vi.fn(async () => ({
|
||||
action: "support" as const,
|
||||
reason: "reload-already-attempted",
|
||||
}));
|
||||
render(
|
||||
<ChunkRecoveryBoundary chunkId="route-home" recover={recover}>
|
||||
<ChunkDefect />
|
||||
</ChunkRecoveryBoundary>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "화면 자산을 복구하지 못했습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(recover).toHaveBeenCalledOnce();
|
||||
expect(recover).toHaveBeenCalledWith({
|
||||
chunkId: "route-home",
|
||||
failureKind: "CHUNK_LOAD_FAILURE",
|
||||
});
|
||||
});
|
||||
|
||||
it("rethrows an ordinary component defect to the local render boundary", () => {
|
||||
const recover = vi.fn();
|
||||
render(
|
||||
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
||||
<ChunkRecoveryBoundary chunkId="route-home" recover={recover}>
|
||||
<RenderDefect />
|
||||
</ChunkRecoveryBoundary>
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"화면을 표시하지 못했습니다.",
|
||||
);
|
||||
expect(recover).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DataTable,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuIcon,
|
||||
Pagination,
|
||||
Popover,
|
||||
RadioGroup,
|
||||
Switch,
|
||||
Tabs,
|
||||
ToastProvider,
|
||||
useToast,
|
||||
} from "../../src/presentation/design-system/index.ts";
|
||||
|
||||
describe("design-system platform interactions", () => {
|
||||
it("keeps decorative icons out of the accessibility tree and names icon actions", () => {
|
||||
render(
|
||||
<>
|
||||
<MenuIcon />
|
||||
<IconButton accessibleName="탐색 열기">
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "탐색 열기" })).toBeVisible();
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports native choices, indeterminate state, radio arrows and switches", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRadio = vi.fn();
|
||||
const onSwitch = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<Checkbox indeterminate label="일부 선택" />
|
||||
<RadioGroup
|
||||
label="밀도"
|
||||
name="density"
|
||||
onChange={onRadio}
|
||||
options={[
|
||||
{ value: "normal", label: "보통" },
|
||||
{ value: "compact", label: "조밀" },
|
||||
]}
|
||||
value="normal"
|
||||
/>
|
||||
<Switch
|
||||
checked={false}
|
||||
label="알림"
|
||||
onChange={onSwitch}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: "일부 선택" });
|
||||
expect(checkbox).toBePartiallyChecked();
|
||||
const normal = screen.getByRole("radio", { name: "보통" });
|
||||
normal.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(onRadio).toHaveBeenCalledWith("compact");
|
||||
await user.click(screen.getByRole("switch", { name: "알림" }));
|
||||
expect(onSwitch).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("moves through a menu, executes once, dismisses and restores focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
const inspect = vi.fn();
|
||||
render(
|
||||
<Menu
|
||||
items={[
|
||||
{ id: "open", label: "열기", onSelect: vi.fn() },
|
||||
{ id: "inspect", label: "검사", onSelect: inspect },
|
||||
]}
|
||||
triggerLabel="작업"
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "작업" });
|
||||
trigger.focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(screen.getByRole("menuitem", { name: "열기" })).toHaveFocus();
|
||||
await user.keyboard("{ArrowDown}{Enter}");
|
||||
expect(inspect).toHaveBeenCalledOnce();
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports manual tab activation with arrow-key roving focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Tabs
|
||||
activation="manual"
|
||||
label="계층"
|
||||
tabs={[
|
||||
{ id: "tokens", label: "토큰", panel: "토큰 내용" },
|
||||
{ id: "patterns", label: "패턴", panel: "패턴 내용" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tokenTab = screen.getByRole("tab", { name: "토큰" });
|
||||
tokenTab.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
const patternTab = screen.getByRole("tab", { name: "패턴" });
|
||||
expect(patternTab).toHaveFocus();
|
||||
expect(patternTab).toHaveAttribute("aria-selected", "false");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(patternTab).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel", { name: "패턴" })).toHaveTextContent(
|
||||
"패턴 내용",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a modal drawer and restores focus after Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>탐색 열기</Button>
|
||||
<Drawer onClose={() => setOpen(false)} open={open} title="탐색">
|
||||
<a href="/target">대상</a>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "탐색 열기" });
|
||||
await user.click(trigger);
|
||||
expect(screen.getByRole("dialog", { name: "탐색" })).toHaveAttribute("open");
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it("bounds the toast queue and collapses duplicate IDs", async () => {
|
||||
const user = userEvent.setup();
|
||||
function Harness() {
|
||||
const toast = useToast();
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast.push({ id: "same", title: "저장됨", durationMs: 60_000 })
|
||||
}
|
||||
>
|
||||
중복
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
toast.push({
|
||||
id: `toast-${index}`,
|
||||
title: `알림 ${index}`,
|
||||
durationMs: 60_000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
다섯 개
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<ToastProvider limit={3}>
|
||||
<Harness />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "중복" }));
|
||||
await user.click(screen.getByRole("button", { name: "중복" }));
|
||||
expect(screen.getByText("×2")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "다섯 개" }));
|
||||
const region = screen.getByRole("region", { name: "알림" });
|
||||
expect(within(region).getAllByRole("article")).toHaveLength(3);
|
||||
expect(within(region).queryByText("알림 0")).not.toBeInTheDocument();
|
||||
expect(within(region).getByText("알림 4")).toBeVisible();
|
||||
});
|
||||
|
||||
it("pauses toast timeout while the user is interacting", () => {
|
||||
vi.useFakeTimers();
|
||||
function Harness() {
|
||||
const toast = useToast();
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast.push({ id: "timed", title: "시간 제한", durationMs: 1000 })
|
||||
}
|
||||
>
|
||||
표시
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Harness />
|
||||
</ToastProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "표시" }));
|
||||
const item = screen.getByRole("article");
|
||||
fireEvent.mouseEnter(item);
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(item).toBeVisible();
|
||||
fireEvent.mouseLeave(item);
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(screen.queryByText("시간 제한")).not.toBeInTheDocument();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("provides dismissible popover and data/navigation patterns", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPage = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<Popover triggerLabel="설명 열기">
|
||||
<Button>내부 작업</Button>
|
||||
</Popover>
|
||||
<DataTable
|
||||
caption="결과"
|
||||
columns={[
|
||||
{ id: "name", header: "이름", cell: (row) => row.name },
|
||||
]}
|
||||
empty="비어 있음"
|
||||
rowKey={(row) => row.id}
|
||||
rows={[{ id: "one", name: "첫 항목" }]}
|
||||
/>
|
||||
<Pagination
|
||||
label="페이지"
|
||||
nextLabel="다음"
|
||||
onChange={onPage}
|
||||
page={1}
|
||||
pageCount={2}
|
||||
pageLabel={(page) => `${page}페이지`}
|
||||
previousLabel="이전"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
const popoverTrigger = screen.getByRole("button", { name: "설명 열기" });
|
||||
await user.click(popoverTrigger);
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("내부 작업");
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(popoverTrigger).toHaveFocus();
|
||||
expect(screen.getByRole("table", { name: "결과" })).toHaveTextContent(
|
||||
"첫 항목",
|
||||
);
|
||||
await user.click(screen.getByRole("link", { name: "2페이지" }));
|
||||
expect(onPage).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Button } from "../../src/presentation/components/ui/button.ts";
|
||||
import { Card } from "../../src/presentation/components/ui/card.ts";
|
||||
|
||||
describe("design-token fixture", () => {
|
||||
it("uses static semantic primitive classes", () => {
|
||||
render(
|
||||
<Card title="Design token fixture">
|
||||
<Button>Token action</Button>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByRole("article")).toHaveClass("ui-card");
|
||||
expect(screen.getByRole("button")).toHaveClass("ui-button");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { createMemoryRouter, RouterProvider, useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
import { Button } from "../../src/presentation/components/ui/button.ts";
|
||||
import {
|
||||
DirtyNavigationDialog,
|
||||
ErrorSummary,
|
||||
Form,
|
||||
FormField,
|
||||
useAppForm,
|
||||
useDirtyNavigationGuard,
|
||||
} from "../../src/presentation/forms/index.ts";
|
||||
|
||||
type Values = Readonly<Record<"name" | "note", string>>;
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().trim().min(2),
|
||||
note: z.string().trim().default(""),
|
||||
})
|
||||
.strict();
|
||||
const defaults: Values = { name: "", note: "" };
|
||||
|
||||
function FormHarness(props: Readonly<{
|
||||
submit(command: Readonly<{ name: string; note?: string }>): Promise<
|
||||
| Readonly<{ ok: true; value: string }>
|
||||
| Readonly<{ ok: false; error: ReturnType<typeof createFailure> }>
|
||||
>;
|
||||
resetOnSuccess?: boolean;
|
||||
}>) {
|
||||
const form = useAppForm({
|
||||
schema,
|
||||
defaultValues: defaults,
|
||||
allowedServerFields: ["name", "note"],
|
||||
mapToCommand(values) {
|
||||
return {
|
||||
name: values.name,
|
||||
...(values.note ? { note: values.note } : {}),
|
||||
};
|
||||
},
|
||||
submit: props.submit,
|
||||
resetOnSuccess: props.resetOnSuccess,
|
||||
});
|
||||
return (
|
||||
<Form pending={form.pending} onSubmit={(event) => void form.submitForm(event)}>
|
||||
<ErrorSummary
|
||||
fieldErrors={form.fieldErrors}
|
||||
formErrors={form.formErrors}
|
||||
fieldLabels={{ name: "Name", note: "Note" }}
|
||||
fieldId={form.fieldId}
|
||||
onFocusField={form.focusField}
|
||||
/>
|
||||
<FormField {...form.field("name")} label="Name" required />
|
||||
<FormField {...form.field("note")} label="Note" />
|
||||
<Button type="submit" disabled={form.pending}>
|
||||
{form.pending ? "Pending" : "Submit"}
|
||||
</Button>
|
||||
<Button onClick={() => form.reset()} disabled={!form.dirty}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button onClick={() => form.settleApplied()}>Confirm applied</Button>
|
||||
<Button onClick={() => form.settleNotApplied()}>
|
||||
Confirm not applied
|
||||
</Button>
|
||||
<output data-testid="dirty">{String(form.dirty)}</output>
|
||||
<output data-testid="result">{form.result}</output>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
describe("local form facade", () => {
|
||||
it("focuses the first invalid field and performs no command", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submit = vi.fn();
|
||||
render(<FormHarness submit={submit} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("textbox", { name: /Name/ })).toHaveFocus();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Name");
|
||||
});
|
||||
|
||||
it("submits transformed data once and clears dirty state after success", async () => {
|
||||
const user = userEvent.setup();
|
||||
let finish: ((value: { ok: true; value: string }) => void) | undefined;
|
||||
const submit = vi.fn(
|
||||
() =>
|
||||
new Promise<{ ok: true; value: string }>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
render(<FormHarness submit={submit} />);
|
||||
await user.type(screen.getByRole("textbox", { name: /Name/ }), " Ready ");
|
||||
await user.type(screen.getByRole("textbox", { name: "Note" }), " Safe ");
|
||||
|
||||
await user.dblClick(screen.getByRole("button", { name: "Submit" }));
|
||||
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
|
||||
expect(submit).toHaveBeenCalledWith({ name: "Ready", note: "Safe" });
|
||||
expect(screen.getByRole("button", { name: "Pending" })).toBeDisabled();
|
||||
finish?.({ ok: true, value: "saved" });
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("dirty")).toHaveTextContent("false"));
|
||||
expect(screen.getByTestId("result")).toHaveTextContent("success");
|
||||
});
|
||||
|
||||
it("maps only approved 422 fields and never renders backend copy", async () => {
|
||||
const user = userEvent.setup();
|
||||
const failure = createFailure(
|
||||
"VALIDATION_REJECTED",
|
||||
"CREATE_ENTITY",
|
||||
0,
|
||||
{
|
||||
validationIssues: [
|
||||
{ path: "name", code: "REQUIRED" },
|
||||
{ path: "serverOnly", code: "raw-secret-message" },
|
||||
],
|
||||
},
|
||||
);
|
||||
render(
|
||||
<FormHarness submit={async () => ({ ok: false, error: failure })} />,
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: /Name/ }), "Valid");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("필수 입력값입니다.");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"서버가 확인하지 못한 입력 항목",
|
||||
);
|
||||
expect(document.body).not.toHaveTextContent("raw-secret-message");
|
||||
});
|
||||
|
||||
it("keeps conflict input out of URL and storage", async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.clear();
|
||||
window.history.replaceState({}, "", "/form-test");
|
||||
render(
|
||||
<FormHarness
|
||||
submit={async () => ({
|
||||
ok: false,
|
||||
error: createFailure("CONFLICT", "CREATE_ENTITY", 0),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const secretLike = "token-like-do-not-copy";
|
||||
await user.type(screen.getByRole("textbox", { name: /Name/ }), secretLike);
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
|
||||
expect(screen.getByRole("textbox", { name: /Name/ })).toHaveValue(secretLike);
|
||||
expect(window.location.href).not.toContain(secretLike);
|
||||
expect(JSON.stringify(localStorage)).not.toContain(secretLike);
|
||||
});
|
||||
|
||||
it("blocks a second submit while the prior effect remains unknown", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submit = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
|
||||
effect: "MAYBE_APPLIED",
|
||||
}),
|
||||
}));
|
||||
render(<FormHarness submit={submit} resetOnSuccess={false} />);
|
||||
const name = screen.getByRole("textbox", { name: /Name/ });
|
||||
await user.type(name, "Alpha");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
expect(await screen.findByTestId("result")).toHaveTextContent(
|
||||
"effect-unknown",
|
||||
);
|
||||
|
||||
await user.clear(name);
|
||||
await user.type(name, "Beta");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
expect(screen.getByTestId("result")).toHaveTextContent("effect-unknown");
|
||||
});
|
||||
|
||||
it("settles the submitted unknown snapshot without accepting later edits", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submit = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
|
||||
effect: "MAYBE_APPLIED",
|
||||
}),
|
||||
}));
|
||||
render(<FormHarness submit={submit} resetOnSuccess={false} />);
|
||||
const name = screen.getByRole("textbox", { name: /Name/ });
|
||||
await user.type(name, "Alpha");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
await screen.findByText("effect-unknown");
|
||||
await user.clear(name);
|
||||
await user.type(name, "Beta");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Confirm applied" }));
|
||||
|
||||
expect(name).toHaveValue("Beta");
|
||||
expect(screen.getByTestId("result")).toHaveTextContent("success");
|
||||
expect(screen.getByTestId("dirty")).toHaveTextContent("true");
|
||||
await user.clear(name);
|
||||
await user.type(name, "Alpha");
|
||||
expect(screen.getByTestId("dirty")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
it("releases an unknown submission only after explicit not-applied settlement", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submit = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false as const,
|
||||
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
|
||||
effect: "MAYBE_APPLIED",
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true as const, value: "saved" });
|
||||
render(<FormHarness submit={submit} resetOnSuccess={false} />);
|
||||
await user.type(screen.getByRole("textbox", { name: /Name/ }), "Alpha");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
await screen.findByText("effect-unknown");
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Confirm not applied" }),
|
||||
);
|
||||
expect(screen.getByTestId("result")).toHaveTextContent("idle");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(submit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirty navigation guard", () => {
|
||||
it("blocks navigation, restores focus on stay and proceeds explicitly", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function GuardedPage() {
|
||||
const navigate = useNavigate();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const guard = useDirtyNavigationGuard(dirty);
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="guard-field">Guard field</label>
|
||||
<input
|
||||
id="guard-field"
|
||||
onChange={() => setDirty(true)}
|
||||
/>
|
||||
<Button onClick={() => navigate("/target")}>Leave</Button>
|
||||
<DirtyNavigationDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: "/", element: <GuardedPage /> },
|
||||
{ path: "/target", element: <h1>Target</h1> },
|
||||
],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
render(<RouterProvider router={router} />);
|
||||
await user.type(screen.getByRole("textbox", { name: "Guard field" }), "x");
|
||||
const leave = screen.getByRole("button", { name: "Leave" });
|
||||
await user.click(leave);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
|
||||
).toHaveAttribute("open");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "계속 작성" }));
|
||||
await waitFor(() => expect(leave).toHaveFocus());
|
||||
await user.click(leave);
|
||||
await user.click(screen.getByRole("button", { name: "변경 버리고 이동" }));
|
||||
expect(await screen.findByRole("heading", { name: "Target" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Pagination,
|
||||
Tabs,
|
||||
} from "../../src/presentation/design-system/index.ts";
|
||||
import {
|
||||
LocaleProvider,
|
||||
useLocale,
|
||||
type SupportedLocale,
|
||||
} from "../../src/presentation/i18n/index.ts";
|
||||
|
||||
function LocaleHarness() {
|
||||
const { direction, locale, message, setLocale } = useLocale();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="test-locale">{message("shell.locale")}</label>
|
||||
<select
|
||||
id="test-locale"
|
||||
onChange={(event) =>
|
||||
setLocale(event.currentTarget.value as SupportedLocale)
|
||||
}
|
||||
value={locale}
|
||||
>
|
||||
<option value="ko-KR">한국어</option>
|
||||
<option value="en-US">English</option>
|
||||
<option value="ar-EG">RTL</option>
|
||||
</select>
|
||||
<output>{`${locale}:${direction}`}</output>
|
||||
<Button onClick={() => setDrawerOpen(true)}>
|
||||
{message("shell.menu")}
|
||||
</Button>
|
||||
<Drawer
|
||||
closeLabel={message("shell.closeMenu")}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
open={drawerOpen}
|
||||
title={message("shell.menu")}
|
||||
>
|
||||
content
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("locale provider runtime", () => {
|
||||
it("switches copy and synchronizes the document language and direction", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LocaleProvider>
|
||||
<LocaleHarness />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
|
||||
expect(document.documentElement).toHaveAttribute("lang", "ko-KR");
|
||||
await user.selectOptions(screen.getByLabelText("언어"), "en-US");
|
||||
expect(screen.getByLabelText("Language")).toHaveValue("en-US");
|
||||
expect(document.documentElement).toHaveAttribute("lang", "en-US");
|
||||
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Language"), "ar-EG");
|
||||
expect(document.documentElement).toHaveAttribute("lang", "ar-EG");
|
||||
expect(document.documentElement).toHaveAttribute("dir", "rtl");
|
||||
await user.click(screen.getByRole("button", { name: "Menu" }));
|
||||
expect(screen.getByRole("dialog", { name: "Menu" })).toHaveAttribute(
|
||||
"open",
|
||||
);
|
||||
});
|
||||
|
||||
it("reverses horizontal tab focus semantics in RTL", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LocaleProvider initialLocale="ar-EG">
|
||||
<Tabs
|
||||
activation="manual"
|
||||
label="sections"
|
||||
tabs={[
|
||||
{ id: "one", label: "One", panel: "One panel" },
|
||||
{ id: "two", label: "Two", panel: "Two panel" },
|
||||
{ id: "three", label: "Three", panel: "Three panel" },
|
||||
]}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
const first = screen.getByRole("tab", { name: "One" });
|
||||
first.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "Three" })).toHaveFocus();
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(first).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps pagination semantics while direction-aware icons remain decorative", () => {
|
||||
render(
|
||||
<LocaleProvider initialLocale="ar-EG">
|
||||
<Pagination
|
||||
label="pages"
|
||||
nextLabel="Next page"
|
||||
onChange={() => {}}
|
||||
page={2}
|
||||
pageCount={3}
|
||||
pageLabel={(page) => `Page ${page}`}
|
||||
previousLabel="Previous page"
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous page" }),
|
||||
).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PageHeader } from "../../src/presentation/components/page-header.tsx";
|
||||
|
||||
describe("page header focus ownership", () => {
|
||||
it("takes over focus handed off by the route main region", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<main id="main-content" tabIndex={-1} />
|
||||
<PageHeader title="Loading" />
|
||||
</>,
|
||||
);
|
||||
const main = screen.getByRole("main");
|
||||
main.focus();
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<main id="main-content" tabIndex={-1} />
|
||||
<PageHeader title="Loaded" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Loaded" }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not steal focus from a user-controlled element", () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<button type="button">Menu</button>
|
||||
<PageHeader title="Loading" />
|
||||
</>,
|
||||
);
|
||||
const menu = screen.getByRole("button", { name: "Menu" });
|
||||
menu.focus();
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<button type="button">Menu</button>
|
||||
<PageHeader title="Loaded" />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(menu).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CollectionPage,
|
||||
DetailPage,
|
||||
FormPage,
|
||||
StandardPage,
|
||||
StatusPage,
|
||||
} from "../../src/presentation/templates/index.ts";
|
||||
|
||||
describe("page template slot contracts", () => {
|
||||
it("renders StandardPage minimum and full landmarks with one h1", () => {
|
||||
const { rerender } = render(
|
||||
<StandardPage heading={{ title: "Minimum" }}>Content</StandardPage>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Minimum" })).toBeVisible();
|
||||
|
||||
rerender(
|
||||
<StandardPage
|
||||
heading={{ title: "Full", description: "Long heading contract" }}
|
||||
breadcrumb={<a href="/">Home</a>}
|
||||
status={<span>Ready</span>}
|
||||
actions={[
|
||||
{ kind: "button", label: "Action", onAction: () => {} },
|
||||
]}
|
||||
notices={<p>Notice</p>}
|
||||
feedback={<p role="status">Refreshing</p>}
|
||||
aside={<p>Aside</p>}
|
||||
>
|
||||
Content
|
||||
</StandardPage>,
|
||||
);
|
||||
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
|
||||
expect(screen.getByRole("navigation", { name: "현재 위치" })).toBeVisible();
|
||||
expect(screen.getByRole("complementary", { name: "관련 정보" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("places collection, detail and form state in stable slots", () => {
|
||||
const { rerender } = render(
|
||||
<CollectionPage
|
||||
heading={{ title: "Collection" }}
|
||||
toolbar={<button type="button">Filter</button>}
|
||||
resultCount="12 results"
|
||||
pagination={<a href="?page=2">Next</a>}
|
||||
>
|
||||
Results
|
||||
</CollectionPage>,
|
||||
);
|
||||
expect(screen.getByRole("region", { name: "검색과 필터" })).toBeVisible();
|
||||
expect(screen.getByRole("navigation", { name: "페이지 탐색" })).toBeVisible();
|
||||
|
||||
rerender(
|
||||
<DetailPage
|
||||
heading={{ title: "Detail" }}
|
||||
metadata={<dl><dt>ID</dt><dd>1</dd></dl>}
|
||||
destructiveAction={<button type="button">Delete</button>}
|
||||
>
|
||||
Sections
|
||||
</DetailPage>,
|
||||
);
|
||||
expect(screen.getByRole("region", { name: "요약 정보" })).toBeVisible();
|
||||
expect(screen.getByRole("region", { name: "위험 작업" })).toBeVisible();
|
||||
|
||||
rerender(
|
||||
<FormPage
|
||||
heading={{ title: "Form" }}
|
||||
errorSummary={<p role="alert">Invalid</p>}
|
||||
fields={<input aria-label="Field" />}
|
||||
formActions={<button type="button">Save</button>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("alert")).toBeVisible();
|
||||
expect(screen.getByRole("textbox", { name: "Field" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("renders safe status variants without raw failure values", () => {
|
||||
render(
|
||||
<StatusPage
|
||||
variant="offline"
|
||||
heading={{ title: "Offline", description: "Safe recovery copy" }}
|
||||
primaryAction={{ kind: "button", label: "Retry", onAction: () => {} }}
|
||||
supportReference="SAFE-123"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Offline" })).toBeVisible();
|
||||
expect(screen.getByText(/SAFE-123/)).toBeVisible();
|
||||
expect(document.body).not.toHaveTextContent("stack");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
import { deriveAsyncState } from "../../src/application/view-models/async-state.ts";
|
||||
import { AsyncSurface } from "../../src/presentation/components/async-surface.tsx";
|
||||
import { BootErrorShell } from "../../src/presentation/boundaries/boot-error-shell.tsx";
|
||||
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.tsx";
|
||||
|
||||
function Defect(): ReactNode {
|
||||
throw new Error("raw render stack");
|
||||
}
|
||||
|
||||
describe("render recovery boundaries", () => {
|
||||
it("catches programmer defects and emits best-effort safe telemetry", () => {
|
||||
const onRenderFailure = vi.fn();
|
||||
render(
|
||||
<FeatureBoundary
|
||||
routeId="APP_HOME"
|
||||
buildId="build-a"
|
||||
onRenderFailure={onRenderFailure}
|
||||
>
|
||||
<Defect />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"화면을 표시하지 못했습니다.",
|
||||
);
|
||||
expect(onRenderFailure).toHaveBeenCalledWith({
|
||||
routeId: "APP_HOME",
|
||||
buildId: "build-a",
|
||||
boundaryName: "feature",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps normalized operational failures in normal async state", () => {
|
||||
const state = deriveAsyncState({
|
||||
failure: createFailure("SERVER_FAILURE", "LIST", 0),
|
||||
});
|
||||
render(
|
||||
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
||||
<AsyncSurface state={state} />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"요청을 완료하지 못했습니다.",
|
||||
);
|
||||
expect(screen.getByRole("alert")).toHaveAttribute(
|
||||
"data-message-key",
|
||||
"error.server_failure",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a safe boot shell with no endpoint or stack", () => {
|
||||
render(
|
||||
<BootErrorShell
|
||||
kind="BOOT_CONFIG_FAILURE"
|
||||
code="CONFIG_SCHEMA_INVALID"
|
||||
buildId="build-a"
|
||||
configSchemaVersion="1"
|
||||
supportReference="build-a:CONFIG_SCHEMA_INVALID"
|
||||
/>,
|
||||
);
|
||||
const shell = screen.getByRole("alert");
|
||||
expect(shell).toHaveTextContent("build-a:CONFIG_SCHEMA_INVALID");
|
||||
expect(shell).not.toHaveTextContent("https://");
|
||||
expect(shell).not.toHaveTextContent("stack");
|
||||
});
|
||||
|
||||
it("allows a boundary reset action without reloading the page", async () => {
|
||||
let shouldThrow = true;
|
||||
function Recoverable() {
|
||||
if (shouldThrow) throw new Error("defect");
|
||||
return <p>recovered</p>;
|
||||
}
|
||||
const user = userEvent.setup();
|
||||
const view = render(
|
||||
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
||||
<Recoverable />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
shouldThrow = false;
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
view.rerender(
|
||||
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
||||
<Recoverable />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
expect(screen.getByText("recovered")).toBeVisible();
|
||||
});
|
||||
|
||||
it("resets a route failure when the registered location key changes", async () => {
|
||||
let shouldThrow = true;
|
||||
function RouteContent() {
|
||||
if (shouldThrow) throw new Error("route defect");
|
||||
return <p>next route</p>;
|
||||
}
|
||||
const view = render(
|
||||
<FeatureBoundary
|
||||
routeId="APP_HOME"
|
||||
buildId="build-a"
|
||||
resetKey="/first"
|
||||
>
|
||||
<RouteContent />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
expect(screen.getByRole("alert")).toBeVisible();
|
||||
|
||||
shouldThrow = false;
|
||||
view.rerender(
|
||||
<FeatureBoundary
|
||||
routeId="APP_HOME"
|
||||
buildId="build-a"
|
||||
resetKey="/second"
|
||||
>
|
||||
<RouteContent />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
expect(await screen.findByText("next route")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
||||
import { AppRouter } from "../../src/presentation/routes/app-router.tsx";
|
||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
||||
|
||||
function renderRouter() {
|
||||
return render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session: createAnonymousSessionAdapter(),
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "페이지를 찾을 수 없습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
expect(screen.getByRole("main")).toBeVisible();
|
||||
});
|
||||
|
||||
it("navigates between registry-backed platform routes", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/");
|
||||
renderRouter();
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("link", { name: "UI 구성요소" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
|
||||
).toBeVisible();
|
||||
expect(window.location.pathname).toBe("/examples/ui");
|
||||
expect(document.title).toBe("UI 구성요소 · Frontend Skeleton");
|
||||
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 { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createRuntimeComposition } from "../../src/bootstrap/create-runtime-composition.ts";
|
||||
import { RuntimeApplication } from "../../src/bootstrap/runtime-application.tsx";
|
||||
|
||||
const runtimeConfig = {
|
||||
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: "1",
|
||||
API_CONTRACT_VERSION: "1",
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
BUILD_ID: "local-build",
|
||||
RELEASE_ID: "local-release",
|
||||
};
|
||||
|
||||
const releaseManifest = {
|
||||
schemaVersion: 1,
|
||||
appVersion: "0.1.0",
|
||||
buildId: "local-build",
|
||||
commitSha: "local",
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: "test-hash",
|
||||
releaseId: "local-release",
|
||||
builtAt: "2026-07-26T00:00:00.000Z",
|
||||
routeChunks: {
|
||||
"route-home": "assets/home.js",
|
||||
"route-examples-ui": "assets/ui.js",
|
||||
"route-examples-states": "assets/states.js",
|
||||
"route-examples-auth": "assets/auth.js",
|
||||
"route-not-found": "assets/not-found.js",
|
||||
},
|
||||
};
|
||||
|
||||
describe("production runtime application tree", () => {
|
||||
it("connects validated config and release through composition and ApplicationProvider", async () => {
|
||||
const fetcher = vi.fn(async (input) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
return Response.json(
|
||||
url.includes("release-manifest") ? releaseManifest : runtimeConfig,
|
||||
);
|
||||
});
|
||||
const composition = await createRuntimeComposition({
|
||||
fetcher,
|
||||
host: {},
|
||||
});
|
||||
|
||||
window.history.pushState({}, "", "/");
|
||||
render(<RuntimeApplication composition={composition} />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "Clean Architecture Frontend",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText("빌드 local-build · 릴리스 local-release"),
|
||||
).toBeVisible();
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(composition).not.toHaveProperty("ports");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// @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[] = [];
|
||||
const mutationIntentFactory = Object.freeze({
|
||||
create() {
|
||||
throw new Error("mutation intent is unused by this provider test");
|
||||
},
|
||||
});
|
||||
function Probe() {
|
||||
renderedClients.push(useQueryClient());
|
||||
return <div>generation-content</div>;
|
||||
}
|
||||
|
||||
render(
|
||||
<ServerStateGenerationProvider
|
||||
store={store}
|
||||
scope={scope}
|
||||
mutationIntentFactory={mutationIntentFactory}
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type StatusProps = Readonly<{
|
||||
label: string;
|
||||
tone: "neutral" | "positive";
|
||||
}>;
|
||||
|
||||
function Status({ label, tone }: StatusProps) {
|
||||
return <output data-tone={tone}>{label}</output>;
|
||||
}
|
||||
|
||||
describe("TSX test tooling", () => {
|
||||
it("parses, lints, type-checks, and renders TSX", () => {
|
||||
render(<Status label="ready" tone="positive" />);
|
||||
expect(screen.getByText("ready")).toHaveAttribute("data-tone", "positive");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Alert } from "../../src/presentation/components/ui/alert.ts";
|
||||
import { Badge } from "../../src/presentation/components/ui/badge.ts";
|
||||
import { Button } from "../../src/presentation/components/ui/button.ts";
|
||||
import { Card } from "../../src/presentation/components/ui/card.ts";
|
||||
import { Dialog } from "../../src/presentation/components/ui/dialog.ts";
|
||||
import { TextField } from "../../src/presentation/components/ui/text-field.ts";
|
||||
|
||||
describe("domain-neutral UI primitives", () => {
|
||||
it("connects field help and validation errors to the input", () => {
|
||||
render(
|
||||
<TextField
|
||||
label="이름"
|
||||
description="표시할 이름입니다."
|
||||
error="이름을 입력해 주세요."
|
||||
required
|
||||
/>,
|
||||
);
|
||||
|
||||
const field = screen.getByRole("textbox", { name: "이름" });
|
||||
expect(field).toBeRequired();
|
||||
expect(field).toHaveAccessibleDescription(
|
||||
"표시할 이름입니다. 이름을 입력해 주세요.",
|
||||
);
|
||||
expect(field).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
|
||||
it("exposes semantic variants without changing native button behavior", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<Button variant="danger" onClick={onClick}>
|
||||
제거
|
||||
</Button>
|
||||
<Button disabled>사용 불가</Button>
|
||||
</>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "제거" }));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole("button", { name: "제거" })).toHaveClass(
|
||||
"ui-button--danger",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "사용 불가" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("labels cards, alerts, and badges with visible content", async () => {
|
||||
const user = userEvent.setup();
|
||||
const dismiss = vi.fn();
|
||||
render(
|
||||
<Card title="상태 카드" footer={<Badge variant="success">준비됨</Badge>}>
|
||||
<Alert title="저장됨" variant="success" onDismiss={dismiss}>
|
||||
안전하게 반영했습니다.
|
||||
</Alert>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("article", { name: "상태 카드" })).toBeVisible();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"저장됨안전하게 반영했습니다.",
|
||||
);
|
||||
expect(screen.getByText("준비됨")).toHaveClass("ui-badge--success");
|
||||
await user.click(screen.getByRole("button", { name: "저장됨 알림 닫기" }));
|
||||
expect(dismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("closes a modal and restores focus to its trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function DialogHarness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>모달 열기</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title="연동 확인"
|
||||
actions={<Button onClick={() => setOpen(false)}>확인</Button>}
|
||||
>
|
||||
안전한 설명
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render(<DialogHarness />);
|
||||
const trigger = screen.getByRole("button", { name: "모달 열기" });
|
||||
await user.click(trigger);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "연동 확인" })).toHaveAttribute(
|
||||
"open",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "확인" }));
|
||||
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
expect(screen.getByRole("dialog", { hidden: true })).not.toHaveAttribute(
|
||||
"open",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
for (const route of Object.values(ROUTE_REGISTRY).map((definition) => {
|
||||
if (definition.path === "*") return "/not-found";
|
||||
return definition.path.replace(":resourceId", "reference-1");
|
||||
})) {
|
||||
test(`@a11y ${route} has no critical or serious axe violations`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(route);
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
const blocking = results.violations.filter((violation) =>
|
||||
["critical", "serious"].includes(violation.impact ?? ""),
|
||||
);
|
||||
expect(blocking).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("@a11y keyboard reaches the primary route action with visible focus", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const action = page.getByRole("link", { name: "플랫폼 구성 보기" });
|
||||
await expect(action).toBeVisible();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(action).toBeFocused();
|
||||
await expect(action).toHaveCSS("outline-style", "solid");
|
||||
});
|
||||
|
||||
test("@a11y reduced-motion policy disables long animation", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/");
|
||||
const duration = await page
|
||||
.locator("body")
|
||||
.evaluate((body) => getComputedStyle(body).animationDuration);
|
||||
expect(["0s", "0.00001s", "1e-05s"]).toContain(duration);
|
||||
});
|
||||
|
||||
test("@a11y opened design-system dialog has no blocking violations", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/examples/ui");
|
||||
await page.getByRole("button", { name: "모달 열기" }).click();
|
||||
const results = await new AxeBuilder({ page })
|
||||
.include(".ui-dialog")
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(
|
||||
results.violations.filter((violation) =>
|
||||
["critical", "serious"].includes(violation.impact ?? ""),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("boots the public app shell", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
|
||||
"Clean Architecture Frontend",
|
||||
);
|
||||
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
});
|
||||
|
||||
test("navigates to a registry-backed example without a page reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page
|
||||
.getByRole("navigation", { name: "주요 탐색" })
|
||||
.getByRole("link", { name: "화면 상태" })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/examples\/states$/);
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: "화면 상태" }),
|
||||
).toBeFocused();
|
||||
});
|
||||
|
||||
test("provides an escape-dismissible mobile navigation", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/");
|
||||
const menu = page.getByRole("button", { name: "메뉴", exact: true });
|
||||
|
||||
await menu.click();
|
||||
await expect(menu).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
await expect(page.locator(".ui-drawer")).toHaveJSProperty("open", true);
|
||||
expect(
|
||||
await page.locator(".ui-drawer").evaluate((drawer) => drawer.matches(":modal")),
|
||||
).toBe(true);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeHidden();
|
||||
await expect(menu).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("boots and navigates the compact production shell", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
const menu = page.getByRole("button", { name: "메뉴", exact: true });
|
||||
await expect(menu).toHaveCSS("min-width", "44px");
|
||||
await menu.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(
|
||||
() => document.documentElement.scrollWidth - window.innerWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("runs menu typeahead, tabs and duplicate toast interactions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/examples/ui");
|
||||
|
||||
const menuTrigger = page.getByRole("button", { name: "작업 메뉴" });
|
||||
await menuTrigger.focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.type("toast");
|
||||
const toastItem = page.getByRole("menuitem", { name: "Toast 표시" });
|
||||
await expect(toastItem).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByText("예제가 저장되었습니다.")).toBeVisible();
|
||||
await expect(menuTrigger).toBeFocused();
|
||||
|
||||
const tokenTab = page.getByRole("tab", { name: "토큰" });
|
||||
await tokenTab.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const primitiveTab = page.getByRole("tab", { name: "프리미티브" });
|
||||
await expect(primitiveTab).toBeFocused();
|
||||
await expect(primitiveTab).toHaveAttribute("aria-selected", "false");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(primitiveTab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(
|
||||
page.getByRole("tabpanel", { name: "프리미티브" }),
|
||||
).toContainText("native semantics");
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("switches the shell locale and keeps pseudo-locale copy within compact layout", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 320, height: 720 });
|
||||
await page.goto("/");
|
||||
|
||||
const locale = page.getByRole("combobox", { name: "언어" });
|
||||
await locale.selectOption("en-US");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "ltr");
|
||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||
|
||||
await page.getByRole("combobox", { name: "Language" }).selectOption("en-XA");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-XA");
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
|
||||
await expect(page.getByRole("button", { name: /Ménú/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("applies RTL to the shell and modal navigation without changing action semantics", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/");
|
||||
await page.getByRole("combobox", { name: "언어" }).selectOption("ar-EG");
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "ar-EG");
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
|
||||
const trigger = page.getByRole("button", { name: "Menu" });
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog", { name: "Menu" })).toBeVisible();
|
||||
await expect(page.getByRole("navigation", { name: "Primary navigation" }))
|
||||
.toHaveCount(1);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
async function openReferenceForm(page: Page) {
|
||||
await page.goto("/examples/reference-resources/new");
|
||||
await page.getByRole("button", { name: "로그인 시작" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Reference resource 만들기" }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test("validates a reference form and focuses the first invalid field", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReferenceForm(page);
|
||||
await page.getByRole("button", { name: "저장" }).click();
|
||||
|
||||
const firstField = page.getByRole("textbox", { name: /새 항목 이름/ });
|
||||
await expect(firstField).toBeFocused();
|
||||
await expect(firstField).toHaveAttribute("aria-invalid", "true");
|
||||
await expect(page.getByRole("alert")).toContainText("입력 내용을 확인해 주세요.");
|
||||
});
|
||||
|
||||
test("guards dirty cancellation and restores focus when writing continues", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReferenceForm(page);
|
||||
await page
|
||||
.getByRole("textbox", { name: /새 항목 이름/ })
|
||||
.fill("Unsaved reference");
|
||||
const cancel = page.getByRole("button", { name: "취소" });
|
||||
await cancel.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "계속 작성" }).click();
|
||||
await expect(cancel).toBeFocused();
|
||||
await expect(page).toHaveURL(/\/examples\/reference-resources\/new$/);
|
||||
});
|
||||
|
||||
test("keeps the form template within a 320px viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 720 });
|
||||
await openReferenceForm(page);
|
||||
const viewport = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.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 = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST;
|
||||
await page.route(
|
||||
"http://localhost:8080/api/reference-resources?*",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: "browser-reference",
|
||||
name: "Browser reference",
|
||||
createdAt: "2026-07-26T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.goto(protectedRoute.path);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "세션이 필요합니다." }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "로그인 시작" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: protectedRoute.title }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("인증됨")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("reflows the UI gallery at the 320px minimum without horizontal overflow", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 320, height: 720 });
|
||||
await page.goto("/examples/ui");
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: "UI 구성요소" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "메뉴", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
const viewport = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth);
|
||||
});
|
||||
|
||||
test("keeps desktop navigation and two-column examples at wide viewports", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/examples/ui");
|
||||
|
||||
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "메뉴", exact: true }),
|
||||
).toBeHidden();
|
||||
await expect(page.locator(".component-grid--two").first()).toHaveCSS(
|
||||
"grid-template-columns",
|
||||
/.+px .+px/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("persists an explicit color scheme through the storage contract", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const selector = page.getByRole("combobox", { name: "색상 테마" });
|
||||
|
||||
await selector.selectOption("dark");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await expect(page.locator("html")).toHaveCSS("color-scheme", "dark");
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(
|
||||
results.violations.filter((violation) =>
|
||||
["critical", "serious"].includes(violation.impact ?? ""),
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
await page.reload();
|
||||
await expect(selector).toHaveValue("dark");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
});
|
||||
|
||||
test("tracks operating-system changes while system preference is selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.goto("/");
|
||||
const selector = page.getByRole("combobox", { name: "색상 테마" });
|
||||
await selector.selectOption("system");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("validates and reports the common text-field flow", async ({ page }) => {
|
||||
await page.goto("/examples/ui");
|
||||
await page.getByRole("button", { name: "입력 확인" }).click();
|
||||
|
||||
const field = page.getByRole("textbox", { name: "프로젝트 이름" });
|
||||
await expect(field).toHaveAttribute("aria-invalid", "true");
|
||||
await expect(field).toHaveAccessibleDescription(
|
||||
/프로젝트 이름을 입력해 주세요/,
|
||||
);
|
||||
|
||||
await field.fill("Starter");
|
||||
await page.getByRole("button", { name: "입력 확인" }).click();
|
||||
await expect(page.getByText("“Starter” 입력을 확인했습니다.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("traps modal interaction and restores focus to the trigger", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/examples/ui");
|
||||
const trigger = page.getByRole("button", { name: "모달 열기" });
|
||||
await trigger.click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "연동 확인" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "연동 확인 닫기" }),
|
||||
).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildRouteUrl,
|
||||
parseRouteInput,
|
||||
} from "../../../src/presentation/routes/route-codecs.ts";
|
||||
import {
|
||||
mapReferenceOperation,
|
||||
toReferenceView,
|
||||
} from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
|
||||
import {
|
||||
validateReferencePayload,
|
||||
validateReferenceRequest,
|
||||
} from "../../../src/features/reference-feature/contracts/reference-schemas.ts";
|
||||
import {
|
||||
REFERENCE_FEATURE_CONTRACT,
|
||||
REFERENCE_FEATURE_ID,
|
||||
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||
} 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 the URL codec", () => {
|
||||
const filters = {
|
||||
tags: ["open", "new"],
|
||||
cursor: "a/b",
|
||||
limit: 5,
|
||||
};
|
||||
const url = buildRouteUrl("REFERENCE_RESOURCE_LIST", { search: filters });
|
||||
expect(url).toBe(
|
||||
"/examples/reference-resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
|
||||
);
|
||||
const parsed = parseRouteInput(
|
||||
"REFERENCE_RESOURCE_LIST",
|
||||
{},
|
||||
new URL(url, "https://app.test").searchParams,
|
||||
);
|
||||
expect(parsed).toMatchObject({
|
||||
success: true,
|
||||
data: { search: filters },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown search and malformed DTO before mapping", () => {
|
||||
expect(
|
||||
parseRouteInput(
|
||||
"REFERENCE_RESOURCE_LIST",
|
||||
{},
|
||||
new URLSearchParams("unknown=value"),
|
||||
),
|
||||
).toEqual({ success: false, code: "ROUTE_SEARCH_INVALID" });
|
||||
expect(
|
||||
validateReferencePayload("ReferenceResourceListPayload", [
|
||||
{ id: "unsafe", name: 42 },
|
||||
]),
|
||||
).toMatchObject({ success: false });
|
||||
expect(
|
||||
mapReferenceOperation("LIST_REFERENCE_RESOURCES", [
|
||||
{ id: "unsafe", name: 42 },
|
||||
]),
|
||||
).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("normalizes request input and maps only owned domain fields", () => {
|
||||
expect(
|
||||
validateReferenceRequest("CreateReferenceResourceCommand", {
|
||||
name: " Example ",
|
||||
}),
|
||||
).toMatchObject({ success: true, data: { name: "Example" } });
|
||||
const model = mapReferenceOperation("CREATE_REFERENCE_RESOURCE", {
|
||||
id: "reference-1",
|
||||
name: "Example",
|
||||
createdAt: "2026-07-26T00:00:00.000Z",
|
||||
});
|
||||
if (!model.ok || !("id" in model.value)) {
|
||||
throw new Error("expected one model");
|
||||
}
|
||||
expect(toReferenceView(model.value)).toEqual({
|
||||
resourceId: "reference-1",
|
||||
title: "Example",
|
||||
createdAt: "2026-07-26T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns route and operation contributions in one removable contract", () => {
|
||||
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.routes)).toEqual([
|
||||
"REFERENCE_RESOURCE_LIST",
|
||||
"REFERENCE_RESOURCE_DETAIL",
|
||||
"REFERENCE_RESOURCE_FORM",
|
||||
"REFERENCE_RESOURCE_STATUS",
|
||||
]);
|
||||
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.apiOperations)).toEqual([
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
"GET_REFERENCE_RESOURCE",
|
||||
]);
|
||||
expect(
|
||||
REFERENCE_FEATURE_CONTRACT.apiOperations.GET_REFERENCE_RESOURCE,
|
||||
).toMatchObject({
|
||||
pathSchema: "ReferenceResourceParams",
|
||||
pathParameterNames: ["resourceId"],
|
||||
providerId: "PRIMARY_API",
|
||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||
csrfProfileId: "NO_CSRF_BEARER",
|
||||
});
|
||||
});
|
||||
|
||||
it("contributes an identity-based invalidation graph and an explicit wire version", () => {
|
||||
expect(REFERENCE_FEATURE_CONTRACT.invalidation).toEqual({
|
||||
topics: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
|
||||
namespaces: [
|
||||
{ namespaceId: "reference-resource", namespaceVersion: 1 },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
topicId: REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||
namespace: {
|
||||
namespaceId: "reference-resource",
|
||||
namespaceVersion: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(REFERENCE_FEATURE_CONTRACT.topicVersions).toEqual([
|
||||
{
|
||||
topicId: REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||
topicVersion: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the installed input through the typed application registry", () => {
|
||||
const featureInput = {
|
||||
listResources: async () => ({ ok: true as const, value: [] }),
|
||||
createResource: async ({ name }) => ({
|
||||
ok: true as const,
|
||||
value: {
|
||||
resourceId: "created",
|
||||
title: name,
|
||||
createdAt: null,
|
||||
},
|
||||
}),
|
||||
getResource: async (resourceId) => ({
|
||||
ok: true as const,
|
||||
value: {
|
||||
resourceId,
|
||||
title: "Reference",
|
||||
createdAt: null,
|
||||
},
|
||||
}),
|
||||
} satisfies ReferenceFeatureInput;
|
||||
const application = createTestApplication({
|
||||
featureInputs: { [REFERENCE_FEATURE_ID]: featureInput },
|
||||
});
|
||||
|
||||
expect(application.features.has(REFERENCE_FEATURE_ID)).toBe(true);
|
||||
expect(application.features.get(REFERENCE_FEATURE_ID)).toBe(featureInput);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createHttpClient } from "../../../src/adapters/http/client.ts";
|
||||
import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
|
||||
import {
|
||||
createReferenceHttpGateway,
|
||||
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
|
||||
import { createReferenceFeatureInput } from "../../../src/features/reference-feature/application/reference-feature-api.ts";
|
||||
import { REFERENCE_FEATURE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts";
|
||||
import { mapReferenceOperation } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
|
||||
import {
|
||||
validateReferencePayload,
|
||||
validateReferenceRequest,
|
||||
} from "../../../src/features/reference-feature/contracts/reference-schemas.ts";
|
||||
|
||||
describe("reference feature diagnostics correlation", () => {
|
||||
it("preserves route, operation and request correlation through the vertical path", async () => {
|
||||
const record = vi.fn();
|
||||
const operations =
|
||||
REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly<
|
||||
Record<
|
||||
string,
|
||||
ReturnType<
|
||||
NonNullable<Parameters<typeof createHttpClient>[0]["getOperation"]>
|
||||
>
|
||||
>
|
||||
>;
|
||||
const client = createHttpClient({
|
||||
baseUrl: "https://api.test",
|
||||
authSession: createDemoSessionAdapter("authenticated"),
|
||||
fetcher: async () =>
|
||||
Response.json({
|
||||
success: true,
|
||||
data: [{ id: "reference-1", name: "Reference" }],
|
||||
meta: { requestId: "safe-request", traceId: "safe-trace" },
|
||||
}),
|
||||
getOperation(operationId) {
|
||||
const operation = operations[operationId];
|
||||
if (!operation) throw new Error("Unregistered reference operation");
|
||||
return operation;
|
||||
},
|
||||
validatePayload: validateReferencePayload,
|
||||
validateRequest: validateReferenceRequest,
|
||||
mapPayload: mapReferenceOperation,
|
||||
correlationIdFactory: () => "reference-correlation",
|
||||
diagnostics: { record },
|
||||
scheduler: {
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
});
|
||||
const application = createReferenceFeatureInput(
|
||||
createReferenceHttpGateway(client),
|
||||
);
|
||||
|
||||
await expect(
|
||||
application.listResources({ limit: 20 }),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(record).toHaveBeenCalledOnce();
|
||||
expect(record).toHaveBeenCalledWith({
|
||||
level: "info",
|
||||
eventId: "http.request.completed",
|
||||
context: expect.objectContaining({
|
||||
route_id: "REFERENCE_RESOURCE_LIST",
|
||||
operation_id: "LIST_REFERENCE_RESOURCES",
|
||||
correlation_id: "reference-correlation",
|
||||
outcome: "success",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createFailure } from "../../../src/contracts/errors.ts";
|
||||
import {
|
||||
createReferenceHttpGateway,
|
||||
type RawReferenceHttpExecutor,
|
||||
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
|
||||
import type { ReferenceResource } from "../../../src/features/reference-feature/domain/reference-resource.ts";
|
||||
|
||||
const resources = Object.freeze({
|
||||
first: Object.freeze({
|
||||
id: "reference-1",
|
||||
displayName: "First",
|
||||
createdAt: null,
|
||||
}),
|
||||
created: Object.freeze({
|
||||
id: "reference-2",
|
||||
displayName: "Created",
|
||||
createdAt: "2026-07-26T00:00:00.000Z",
|
||||
}),
|
||||
}) satisfies Readonly<Record<string, ReferenceResource>>;
|
||||
|
||||
describe("reference HTTP operation gateway", () => {
|
||||
it("builds the exact registered request for every gateway operation", async () => {
|
||||
const execute = vi
|
||||
.fn<RawReferenceHttpExecutor["execute"]>()
|
||||
.mockResolvedValueOnce({ ok: true, value: [resources.first] })
|
||||
.mockResolvedValueOnce({ ok: true, value: resources.created })
|
||||
.mockResolvedValueOnce({ ok: true, value: resources.first });
|
||||
const gateway = createReferenceHttpGateway({ execute });
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
await expect(
|
||||
gateway.list({ cursor: "next", limit: 20, tags: ["active"] }, { signal }),
|
||||
).resolves.toEqual({ ok: true, value: [resources.first] });
|
||||
await expect(
|
||||
gateway.create({ name: "Created", note: "safe note" }),
|
||||
).resolves.toEqual({ ok: true, value: resources.created });
|
||||
await expect(
|
||||
gateway.get("reference-1", { signal }),
|
||||
).resolves.toEqual({ ok: true, value: resources.first });
|
||||
|
||||
expect(execute.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
operationId: "LIST_REFERENCE_RESOURCES",
|
||||
routeId: "REFERENCE_RESOURCE_LIST",
|
||||
searchParams: {
|
||||
cursor: "next",
|
||||
limit: 20,
|
||||
tags: ["active"],
|
||||
},
|
||||
signal,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
||||
routeId: "REFERENCE_RESOURCE_LIST",
|
||||
body: { name: "Created", note: "safe note" },
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
operationId: "GET_REFERENCE_RESOURCE",
|
||||
routeId: "REFERENCE_RESOURCE_DETAIL",
|
||||
pathParams: { resourceId: "reference-1" },
|
||||
signal,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves a validated raw failure without casting it into success", async () => {
|
||||
const failure = createFailure(
|
||||
"SCHEMA_MISMATCH",
|
||||
"GET_REFERENCE_RESOURCE",
|
||||
0,
|
||||
);
|
||||
const execute = vi
|
||||
.fn<RawReferenceHttpExecutor["execute"]>()
|
||||
.mockResolvedValue({ ok: false, error: failure });
|
||||
const gateway = createReferenceHttpGateway({ execute });
|
||||
|
||||
await expect(gateway.get("invalid")).resolves.toEqual({
|
||||
ok: false,
|
||||
error: failure,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a raw success does not match its operation result", async () => {
|
||||
const execute = vi
|
||||
.fn<RawReferenceHttpExecutor["execute"]>()
|
||||
.mockResolvedValue({ ok: true, value: { id: "not-a-list" } });
|
||||
const gateway = createReferenceHttpGateway({ execute });
|
||||
|
||||
await expect(gateway.list({ limit: 20 })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "MAPPING_CONTRACT_VIOLATION",
|
||||
code: "BOUND_RESULT_TYPE_MISMATCH",
|
||||
operationId: "LIST_REFERENCE_RESOURCES",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,490 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
|
||||
import type { AuthSessionPort } from "../../../src/application/ports/auth-session-port.ts";
|
||||
import type { MutationIntentFactory } from "../../../src/application/ports/mutation-intent-factory.ts";
|
||||
import type {
|
||||
ReferenceFeatureInput,
|
||||
ReferenceResult,
|
||||
} from "../../../src/features/reference-feature/application/reference-feature-api.ts";
|
||||
import {
|
||||
REFERENCE_FEATURE_ID,
|
||||
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE,
|
||||
} from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts";
|
||||
import { INVALIDATION_REGISTRY } from "../../../src/features/installed-feature-contracts.ts";
|
||||
import type { ReferenceResourceView } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
|
||||
import { createFailure } from "../../../src/contracts/errors.ts";
|
||||
import type { QueryInvalidationCoordinator } from "../../../src/contracts/query-invalidation.ts";
|
||||
import { QueryInvalidationProvider } from "../../../src/presentation/adapters/query/query-invalidation-provider.tsx";
|
||||
import { MutationIntentProvider } from "../../../src/presentation/adapters/query/mutation-intent-provider.tsx";
|
||||
import { ServerStateScopeProvider } from "../../../src/presentation/adapters/query/server-state-scope-provider.tsx";
|
||||
import { createServerStateScopeRuntime } from "../../../src/adapters/query-cache/server-state-scope-runtime.ts";
|
||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||
import { AppRouter } from "../../../src/presentation/routes/app-router.tsx";
|
||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||
|
||||
function renderReference(
|
||||
input: ReferenceFeatureInput,
|
||||
url = "/examples/reference-resources?limit=5",
|
||||
session: AuthSessionPort = createDemoSessionAdapter("authenticated"),
|
||||
) {
|
||||
window.history.pushState({}, "", url);
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const invalidation: QueryInvalidationCoordinator = Object.freeze({
|
||||
async invalidate() {},
|
||||
beginMutation() {
|
||||
return Object.freeze({
|
||||
async release() {},
|
||||
});
|
||||
},
|
||||
async resetLocal() {},
|
||||
dispose() {},
|
||||
});
|
||||
const serverStateScope = createServerStateScopeRuntime({
|
||||
session,
|
||||
queryInvalidation: invalidation,
|
||||
});
|
||||
let intentSequence = 0;
|
||||
const mutationIntentFactory: MutationIntentFactory = Object.freeze({
|
||||
create(input) {
|
||||
intentSequence += 1;
|
||||
return Object.freeze({
|
||||
intentId: `reference-page-intent-${intentSequence}`,
|
||||
operationId: input.operationId,
|
||||
canonicalInputIdentity: input.canonicalInputIdentity,
|
||||
...(input.requiresIdempotencyKey
|
||||
? { idempotencyKey: `reference-page-key-${intentSequence}` }
|
||||
: {}),
|
||||
createdAtMonotonicMs: intentSequence,
|
||||
});
|
||||
},
|
||||
});
|
||||
return Object.assign(render(
|
||||
<MutationIntentProvider factory={mutationIntentFactory}>
|
||||
<QueryClientProvider client={client}>
|
||||
<ServerStateScopeProvider runtime={serverStateScope}>
|
||||
<QueryInvalidationProvider coordinator={invalidation}>
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session,
|
||||
featureInputs: { [REFERENCE_FEATURE_ID]: input },
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>
|
||||
</QueryInvalidationProvider>
|
||||
</ServerStateScopeProvider>
|
||||
</QueryClientProvider>
|
||||
</MutationIntentProvider>,
|
||||
), { client });
|
||||
}
|
||||
|
||||
function inputWith(
|
||||
overrides: Partial<ReferenceFeatureInput> = {},
|
||||
): ReferenceFeatureInput {
|
||||
return {
|
||||
listResources: async () => ({ ok: true, value: [] }),
|
||||
createResource: async ({ name }) => ({
|
||||
ok: true,
|
||||
value: {
|
||||
resourceId: "created",
|
||||
title: name,
|
||||
createdAt: null,
|
||||
},
|
||||
}),
|
||||
getResource: async (resourceId) => ({
|
||||
ok: true,
|
||||
value: {
|
||||
resourceId,
|
||||
title: "Detail",
|
||||
createdAt: null,
|
||||
},
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reference feature page states", () => {
|
||||
it("mounts list and detail keys under the installed governed namespace", async () => {
|
||||
expect(REFERENCE_RESOURCE_QUERY_NAMESPACE).toEqual({
|
||||
namespaceId: "reference-resource",
|
||||
namespaceVersion: 1,
|
||||
});
|
||||
const installedEdge = INVALIDATION_REGISTRY.edges.find(
|
||||
(edge) => edge.topicId === REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||
);
|
||||
expect(installedEdge?.namespace).toEqual(
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE,
|
||||
);
|
||||
|
||||
const list = renderReference(inputWith());
|
||||
await screen.findByRole("heading", { name: "표시할 항목이 없습니다." });
|
||||
const listKey = list.client.getQueryCache().getAll()[0]?.queryKey;
|
||||
expect(listKey?.slice(0, 4)).toEqual([
|
||||
"query",
|
||||
2,
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
|
||||
]);
|
||||
list.unmount();
|
||||
|
||||
const detail = renderReference(
|
||||
inputWith(),
|
||||
"/examples/reference-resources/reference-1",
|
||||
);
|
||||
await screen.findByText("Detail");
|
||||
const detailKey = detail.client.getQueryCache().getAll()[0]?.queryKey;
|
||||
expect(detailKey?.slice(0, 4)).toEqual([
|
||||
"query",
|
||||
2,
|
||||
installedEdge?.namespace.namespaceId,
|
||||
installedEdge?.namespace.namespaceVersion,
|
||||
]);
|
||||
detail.unmount();
|
||||
});
|
||||
|
||||
it("renders loading, success and empty states through the installed route", async () => {
|
||||
let resolveList:
|
||||
| ((result: ReferenceResult<readonly ReferenceResourceView[]>) => void)
|
||||
| undefined;
|
||||
const pending = new Promise<
|
||||
ReferenceResult<readonly ReferenceResourceView[]>
|
||||
>((resolve) => {
|
||||
resolveList = resolve;
|
||||
});
|
||||
const loaded = renderReference(
|
||||
inputWith({ listResources: async () => pending }),
|
||||
);
|
||||
expect(await screen.findByLabelText("불러오는 중")).toBeVisible();
|
||||
resolveList?.({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
resourceId: "reference-1",
|
||||
title: "Loaded",
|
||||
createdAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await screen.findByText("Loaded")).toBeVisible();
|
||||
loaded.unmount();
|
||||
|
||||
renderReference(inputWith(), "/examples/reference-resources?limit=10");
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "표시할 항목이 없습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("rejects invalid URL input before the feature application input", async () => {
|
||||
const listResources = vi.fn();
|
||||
renderReference(
|
||||
inputWith({ listResources }),
|
||||
"/examples/reference-resources?limit=invalid",
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "올바르지 않은 주소입니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(listResources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders backend forbidden even when the client access hint allowed entry", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderReference(
|
||||
inputWith({
|
||||
listResources: async () => ({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"FORBIDDEN",
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
0,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"이 작업을 수행할 권한이 없습니다.",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "안전한 화면으로 이동" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
level: 1,
|
||||
name: "Clean Architecture Frontend",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("starts sign-in with the current route for a backend auth failure", async () => {
|
||||
const user = userEvent.setup();
|
||||
const demoSession = createDemoSessionAdapter("authenticated");
|
||||
const beginSignIn = vi.fn(async () => {});
|
||||
const session = { ...demoSession, beginSignIn };
|
||||
renderReference(
|
||||
inputWith({
|
||||
listResources: async () => ({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"AUTH_REQUIRED",
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
0,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
"/examples/reference-resources?limit=5",
|
||||
session,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "로그인" }));
|
||||
expect(beginSignIn).toHaveBeenCalledWith(
|
||||
"/examples/reference-resources?limit=5",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to a public route when starting sign-in fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const demoSession = createDemoSessionAdapter("authenticated");
|
||||
const beginSignIn = vi.fn(async () => {
|
||||
throw new Error("identity provider unavailable");
|
||||
});
|
||||
renderReference(
|
||||
inputWith({
|
||||
listResources: async () => ({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"AUTH_REQUIRED",
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
0,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
"/examples/reference-resources?limit=5",
|
||||
{ ...demoSession, beginSignIn },
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "로그인" }));
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
level: 1,
|
||||
name: "Clean Architecture Frontend",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("deduplicates create, preserves input and surfaces a conflict", async () => {
|
||||
const user = userEvent.setup();
|
||||
let finish:
|
||||
| ((result: ReferenceResult<ReferenceResourceView>) => void)
|
||||
| undefined;
|
||||
const createResource = vi.fn(
|
||||
() =>
|
||||
new Promise<ReferenceResult<ReferenceResourceView>>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
renderReference(
|
||||
inputWith({ createResource }),
|
||||
"/examples/reference-resources/new",
|
||||
);
|
||||
await screen.findByRole("heading", {
|
||||
name: "Reference resource 만들기",
|
||||
});
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
||||
"Conflicting",
|
||||
);
|
||||
await user.type(screen.getByLabelText("설명"), "Keep this input");
|
||||
const submit = screen.getByRole("button", { name: "저장" });
|
||||
await user.dblClick(submit);
|
||||
|
||||
await waitFor(() => expect(createResource).toHaveBeenCalledOnce());
|
||||
expect(screen.getByRole("button", { name: "저장 중…" })).toBeDisabled();
|
||||
|
||||
finish?.({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"CONFLICT",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
{ effect: "NOT_APPLIED" },
|
||||
),
|
||||
});
|
||||
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
||||
).toHaveValue("Conflicting");
|
||||
expect(screen.getByLabelText("설명")).toHaveValue("Keep this input");
|
||||
});
|
||||
|
||||
it("blocks resubmit and exposes only reconciliation for an unknown create effect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const createResource = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
{ effect: "MAYBE_APPLIED" },
|
||||
),
|
||||
}));
|
||||
renderReference(
|
||||
inputWith({ createResource }),
|
||||
"/examples/reference-resources/new",
|
||||
);
|
||||
await user.type(
|
||||
await screen.findByRole("textbox", { name: /새 항목 이름/ }),
|
||||
"Unknown result",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("변경 결과를 확인할 수 없습니다."),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||
expect(
|
||||
screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
expect(createResource).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
||||
).toHaveValue("Unknown result");
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "변경되지 않음으로 확인" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeEnabled(),
|
||||
);
|
||||
expect(createResource).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
||||
).toHaveValue("Unknown result");
|
||||
});
|
||||
|
||||
it("settles the form after confirming an unknown create was applied", async () => {
|
||||
const user = userEvent.setup();
|
||||
const createResource = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
{ effect: "MAYBE_APPLIED" },
|
||||
),
|
||||
}));
|
||||
renderReference(
|
||||
inputWith({ createResource }),
|
||||
"/examples/reference-resources/new",
|
||||
);
|
||||
const name = await screen.findByRole("textbox", {
|
||||
name: /새 항목 이름/,
|
||||
});
|
||||
await user.type(name, "Already created");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "변경됨으로 확인" }),
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다.");
|
||||
expect(name).toHaveValue("");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
expect(createResource).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("treats an applied-confirmed failure as a settled create", async () => {
|
||||
const user = userEvent.setup();
|
||||
const createResource = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
{ effect: "APPLIED_CONFIRMED" },
|
||||
),
|
||||
}));
|
||||
renderReference(
|
||||
inputWith({ createResource }),
|
||||
"/examples/reference-resources/new",
|
||||
);
|
||||
const name = await screen.findByRole("textbox", {
|
||||
name: /새 항목 이름/,
|
||||
});
|
||||
await user.type(name, "Committed despite response");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다.");
|
||||
expect(name).toHaveValue("");
|
||||
expect(
|
||||
screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
expect(createResource).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
|
||||
const user = userEvent.setup();
|
||||
const listResources = vi
|
||||
.fn<ReferenceFeatureInput["listResources"]>()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
resourceId: "existing",
|
||||
title: "Existing",
|
||||
createdAt: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
0,
|
||||
),
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
resourceId: "recovered",
|
||||
title: "Recovered",
|
||||
createdAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
renderReference(inputWith({ listResources }));
|
||||
await screen.findByText("Existing");
|
||||
await user.click(screen.getByRole("button", { name: "새로고침" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("기존 정보를 표시하고 있습니다."),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText("Existing")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(await screen.findByText("Recovered")).toBeVisible();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("stale-degraded")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(listResources).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { createRuntimeComposition } from "../../../src/bootstrap/create-runtime-composition.ts";
|
||||
import { RuntimeApplication } from "../../../src/bootstrap/runtime-application.tsx";
|
||||
import { createBootstrapHandlers } from "../../mocks/handlers/bootstrap.ts";
|
||||
import { createReferenceScenarioHandlers } from "../../mocks/handlers/reference-resources.ts";
|
||||
import { createStrictMockServer } from "../../mocks/server.ts";
|
||||
|
||||
const runtimeConfig = {
|
||||
APP_ENV: "local",
|
||||
API_BASE_URL: "https://api.test",
|
||||
REQUEST_TIMEOUT_MS: 10_000,
|
||||
MAX_RETRY_ATTEMPTS: 0,
|
||||
TELEMETRY_ENABLED: false,
|
||||
AUTH_MODE: "demo",
|
||||
CONFIG_SCHEMA_VERSION: "1",
|
||||
API_CONTRACT_VERSION: "1",
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
BUILD_ID: "local-build",
|
||||
RELEASE_ID: "local-release",
|
||||
};
|
||||
const releaseManifest = {
|
||||
schemaVersion: 1,
|
||||
appVersion: "0.1.0",
|
||||
buildId: "local-build",
|
||||
commitSha: "local",
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: "test-hash",
|
||||
releaseId: "local-release",
|
||||
builtAt: "2026-07-26T00:00:00.000Z",
|
||||
routeChunks: {
|
||||
"route-home": "assets/home.js",
|
||||
"route-examples-ui": "assets/ui.js",
|
||||
"route-examples-states": "assets/states.js",
|
||||
"route-examples-auth": "assets/auth.js",
|
||||
"route-reference-resources": "assets/reference.js",
|
||||
"route-reference-resource-detail": "assets/reference-detail.js",
|
||||
"route-reference-resource-form": "assets/reference-form.js",
|
||||
"route-reference-resource-status": "assets/reference-status.js",
|
||||
"route-not-found": "assets/not-found.js",
|
||||
},
|
||||
};
|
||||
|
||||
const listRequests = vi.fn();
|
||||
const createRequests = vi.fn();
|
||||
const resources = [{ id: "reference-1", name: "Existing" }];
|
||||
const mockApi = createStrictMockServer(
|
||||
...createBootstrapHandlers(runtimeConfig, releaseManifest),
|
||||
...createReferenceScenarioHandlers({
|
||||
resources,
|
||||
onList: listRequests,
|
||||
onCreate: createRequests,
|
||||
}),
|
||||
);
|
||||
|
||||
beforeAll(mockApi.listen);
|
||||
afterEach(() => {
|
||||
mockApi.reset();
|
||||
listRequests.mockClear();
|
||||
createRequests.mockClear();
|
||||
resources.splice(1);
|
||||
});
|
||||
afterAll(mockApi.close);
|
||||
|
||||
const absoluteFetch: typeof fetch = (input, init) => {
|
||||
if (input instanceof Request) return fetch(input, init);
|
||||
const url = new URL(
|
||||
input instanceof URL ? input.href : input,
|
||||
"http://app.test",
|
||||
);
|
||||
return fetch(url, init);
|
||||
};
|
||||
|
||||
describe("reference feature production vertical path", () => {
|
||||
it("traverses bootstrap, router, application, HTTP schema/mapper and query cache", async () => {
|
||||
const user = userEvent.setup();
|
||||
const composition = await createRuntimeComposition({
|
||||
fetcher: absoluteFetch,
|
||||
host: {},
|
||||
});
|
||||
window.history.pushState(
|
||||
{},
|
||||
"",
|
||||
"/examples/reference-resources?tags=open&tags=new&limit=5",
|
||||
);
|
||||
render(<RuntimeApplication composition={composition} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "로그인 시작" }),
|
||||
);
|
||||
expect(await screen.findByText("Existing")).toBeVisible();
|
||||
expect(listRequests).toHaveBeenCalledWith(
|
||||
"?limit=5&tags=open&tags=new",
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "새 항목 만들기" }));
|
||||
await user.type(
|
||||
await screen.findByRole("textbox", { name: /새 항목 이름/ }),
|
||||
" Created ",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
expect(await screen.findByText("저장했습니다.")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "목록으로 돌아가기" }));
|
||||
expect(await screen.findByText("Created")).toBeVisible();
|
||||
expect(createRequests).toHaveBeenCalledWith({ name: "Created" });
|
||||
expect(listRequests.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createRuntimeAdapters } from "../../../src/bootstrap/runtime-adapters.ts";
|
||||
import { createRuntimeIdentityRegistry } from "../../../src/contracts/query-keys.ts";
|
||||
import { bindQuery } from "../../../src/contracts/server-state.ts";
|
||||
import type { CacheScopeSnapshot } from "../../../src/contracts/server-state-scope.ts";
|
||||
import {
|
||||
REFERENCE_FEATURE_ID,
|
||||
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||
} from "../../../src/features/reference-feature/contracts/reference-feature-contract.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",
|
||||
},
|
||||
},
|
||||
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" },
|
||||
};
|
||||
|
||||
function referenceBoundQueryKey() {
|
||||
const scope: CacheScopeSnapshot = {
|
||||
generation: 1,
|
||||
fingerprint: "runtime-scope-fingerprint-0001",
|
||||
identities: createRuntimeIdentityRegistry({
|
||||
tokenFactory: () => "runtime-identity-token-0001",
|
||||
}),
|
||||
signal: new AbortController().signal,
|
||||
isCurrent: () => true,
|
||||
};
|
||||
return bindQuery(
|
||||
{
|
||||
definitionId: "reference-resource-runtime-test-v1",
|
||||
definitionVersion: 1,
|
||||
owner: REFERENCE_FEATURE_ID,
|
||||
namespace: "reference-resource",
|
||||
namespaceVersion: 1,
|
||||
operationId: "GET_REFERENCE_RESOURCE",
|
||||
profileId: "DETAIL_STANDARD",
|
||||
measureResult: () => ({ itemCount: 1, estimatedBytes: 8 }),
|
||||
execute: async () => ({ ok: true as const, value: "reference-1" }),
|
||||
},
|
||||
"reference-1",
|
||||
scope,
|
||||
).queryKey;
|
||||
}
|
||||
|
||||
describe("reference feature runtime composition", () => {
|
||||
it("invalidates a real bound query through the installed production graph", async () => {
|
||||
const adapters = await createRuntimeAdapters({ runtime, release, host: {} });
|
||||
const queryKey = referenceBoundQueryKey();
|
||||
adapters.infrastructure.queryClient.setQueryData(queryKey, {
|
||||
resourceId: "reference-1",
|
||||
});
|
||||
|
||||
await adapters.infrastructure.queryInvalidation.invalidate([
|
||||
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||
]);
|
||||
|
||||
expect(
|
||||
adapters.infrastructure.queryClient.getQueryState(queryKey)?.isInvalidated,
|
||||
).toBe(true);
|
||||
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("keeps private command intent identity out of URLs and diagnostics", async () => {
|
||||
const requests: Array<Readonly<{ url: string; headers: Headers }>> = [];
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push({
|
||||
url: String(input),
|
||||
headers: new Headers(init?.headers),
|
||||
});
|
||||
return Response.json(
|
||||
{ id: "resource-1", name: "Created resource" },
|
||||
{ status: 201 },
|
||||
);
|
||||
});
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime,
|
||||
release,
|
||||
host: {},
|
||||
fetcher,
|
||||
});
|
||||
await adapters.outputPorts.session.beginSignIn();
|
||||
await vi.waitFor(() =>
|
||||
expect(adapters.infrastructure.serverStateScope.getPhase()).toBe("READY"),
|
||||
);
|
||||
const intent = Object.freeze({
|
||||
intentId: "private-intent-id",
|
||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
||||
canonicalInputIdentity: "private-canonical-input",
|
||||
idempotencyKey: "private-idempotency-key",
|
||||
createdAtMonotonicMs: 42,
|
||||
});
|
||||
|
||||
await expect(
|
||||
adapters.featureInputs[REFERENCE_FEATURE_ID].createResource(
|
||||
{ name: "Created resource" },
|
||||
{ intent },
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]?.headers.get("Idempotency-Key")).toBe(
|
||||
"private-idempotency-key",
|
||||
);
|
||||
expect(requests[0]?.url).toBe(
|
||||
"http://localhost:8080/api/reference-resources",
|
||||
);
|
||||
const safeEvidence = JSON.stringify({
|
||||
requests: requests.map((request) => request.url),
|
||||
diagnostics: adapters.outputPorts.diagnostics.entries(),
|
||||
});
|
||||
expect(safeEvidence).not.toContain("private-intent-id");
|
||||
expect(safeEvidence).not.toContain("private-canonical-input");
|
||||
expect(safeEvidence).not.toContain("private-idempotency-key");
|
||||
adapters.infrastructure.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createRuntimeAdapters } from "../../../../src/bootstrap/runtime-adapters.ts";
|
||||
|
||||
export const bootstrapFactory = createRuntimeAdapters;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createApplication } from "../../../../src/application/create-application.ts";
|
||||
|
||||
export const applicationFactory = createApplication;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApplication } from "../../../../src/application/create-application.ts";
|
||||
|
||||
export const applicationFactory = createApplication;
|
||||
|
||||
export function AllowedPresentationFixture() {
|
||||
return <p>allowed</p>;
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import type { Value } from "../domain/value.ts";
|
||||
|
||||
export function readValue(): Value {
|
||||
return { id: "fixture-value" };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type Value = {
|
||||
readonly id: string;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { readValue } from "../application/read-value.ts";
|
||||
|
||||
export function ValueView() {
|
||||
return <p>{readValue().id}</p>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { second } from "./second.ts";
|
||||
|
||||
export const first = `first:${second}`;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { first } from "./first.ts";
|
||||
|
||||
export const second = `second:${first}`;
|
||||
@@ -0,0 +1 @@
|
||||
export const runtimeName = "forbidden-runtime";
|
||||
@@ -0,0 +1,3 @@
|
||||
import { runtimeName } from "../adapters/runtime.ts";
|
||||
|
||||
export const selectedRuntime = runtimeName;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
const modulePath = "../domain/value.ts";
|
||||
|
||||
export async function loadValue() {
|
||||
return import(modulePath);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import "/definitely-missing-architecture-fixture.ts";
|
||||
import "architecture-fixture-package-that-does-not-exist";
|
||||
import "file:///definitely-missing-architecture-fixture.ts";
|
||||
import type { Value } from "../domain/missing-value.ts";
|
||||
|
||||
export function readValue(value: Value) {
|
||||
return value;
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
const modulePath = "../domain/value.ts";
|
||||
|
||||
export function requireValue() {
|
||||
return require(modulePath);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createHttpClient } from "../../../../src/adapters/http/client.ts";
|
||||
|
||||
export const leakedHttpFactory = createHttpClient;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const invalidClient = new QueryClient();
|
||||
@@ -0,0 +1,3 @@
|
||||
import React from "react";
|
||||
|
||||
export const invalidDomainValue = React.createElement("div");
|
||||
@@ -0,0 +1,4 @@
|
||||
export function DirectFetchPage() {
|
||||
void fetch("/api/forbidden");
|
||||
return <p>forbidden</p>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "../../../../src/adapters/http/client.ts";
|
||||
|
||||
export const invalidEdge = true;
|
||||
@@ -0,0 +1,5 @@
|
||||
import "../../../../src/adapters/http/client.ts";
|
||||
|
||||
export function InvalidPresentationFixture() {
|
||||
return <p>invalid</p>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ApplicationOutputPorts } from "../../../../src/application/ports/out/application-output-ports.ts";
|
||||
|
||||
export function OutputPortLeak(_props: ApplicationOutputPorts) {
|
||||
return <p>forbidden</p>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const leakedQueryHook = useQuery;
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ApplicationApi } from "../../../../src/application/ports/in/application-api.ts";
|
||||
|
||||
export function ForbiddenTemplate(_props: { application: ApplicationApi }) {
|
||||
return <main>Template must not select an application use case.</main>;
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export function createOwnedBrowserStorageCapabilities() {
|
||||
return {
|
||||
local: localStorage,
|
||||
session: sessionStorage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function createOwnedCrossContextCapabilities() {
|
||||
return {
|
||||
channel: new BroadcastChannel("owned-cross-context-fixture"),
|
||||
storage: localStorage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { BrowserStoragePolicy } from "../../../../src/application/ports/browser-file-storage/shared.ts";
|
||||
|
||||
export const POLICY: BrowserStoragePolicy = {
|
||||
owner: "fixture-owner",
|
||||
namespace: "fixture",
|
||||
classification: "INTERNAL",
|
||||
authority: "RECONSTRUCTABLE",
|
||||
accountScope: "OPAQUE_PARTITION",
|
||||
retention: { kind: "TTL", maxAgeMs: 1_000 },
|
||||
softBudgetBytes: 1_000,
|
||||
hardBudgetBytes: 2_000,
|
||||
evictionPriority: "RECONSTRUCTABLE",
|
||||
logoutAction: "EXPORT_THEN_PURGE",
|
||||
accountDeletionAction: "PURGE_PARTITION",
|
||||
pressureAction: "RETAIN",
|
||||
unavailableFallback: "ONLINE_ONLY",
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export function bypassThroughAlias() {
|
||||
const browser = globalThis;
|
||||
return browser.indexedDB.open("forbidden");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
class BrowserRootHolder {
|
||||
readonly root = globalThis;
|
||||
}
|
||||
|
||||
new BrowserRootHolder().root.indexedDB.open("forbidden");
|
||||
@@ -0,0 +1,4 @@
|
||||
export function bypassThroughComputedAlias() {
|
||||
const browser = globalThis;
|
||||
return browser["caches"].open("forbidden");
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
class BrowserRootBox {
|
||||
constructor(readonly root: typeof globalThis) {}
|
||||
}
|
||||
|
||||
new BrowserRootBox(globalThis).root.indexedDB.open("forbidden");
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export function bypassThroughDefault(browser = globalThis) {
|
||||
return browser.indexedDB.open("forbidden");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function constructBlobOutsideOwnedAdapter() {
|
||||
return new Blob(["forbidden"]);
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export function openBroadcastChannelDirectly() {
|
||||
return new BroadcastChannel("forbidden");
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export function openCacheDirectly() {
|
||||
return caches.open("forbidden");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function openDatabaseDirectly() {
|
||||
return indexedDB.open("forbidden");
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export function writeLocalStorageDirectly() {
|
||||
localStorage.setItem("forbidden", "value");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function openOpfsDirectly() {
|
||||
return navigator.storage.getDirectory();
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export function writeSessionStorageDirectly() {
|
||||
sessionStorage.setItem("forbidden", "value");
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export function bypassThroughDynamicCapabilityKey() {
|
||||
const browser = globalThis;
|
||||
const capability = "indexedDB";
|
||||
return browser[capability].open("forbidden");
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export function bypassThroughObjectContainer() {
|
||||
const holder = { browser: globalThis };
|
||||
return holder.browser.indexedDB.open("forbidden");
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export function bypassThroughGlobalThis() {
|
||||
return globalThis.indexedDB.open("forbidden");
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
function identity<Value>(value: Value): Value {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function bypassThroughIdentityWrapper() {
|
||||
return identity(globalThis).indexedDB.open("forbidden");
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
const holder: { root?: typeof globalThis } = {};
|
||||
|
||||
holder.root = globalThis;
|
||||
holder.root.indexedDB.open("forbidden");
|
||||
@@ -0,0 +1,3 @@
|
||||
export function allocateObjectUrlDirectly(value: never) {
|
||||
return URL.createObjectURL(value);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export function bypassThroughPropertyDescriptor() {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
"indexedDB",
|
||||
);
|
||||
return (descriptor?.value as IDBFactory).open("forbidden");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function bypassThroughReflection() {
|
||||
return Reflect.get(globalThis, "indexedDB");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"providerAdapter": ".gitea/workflows/quality-gates.yml",
|
||||
"commands": [],
|
||||
"artifactSchemas": [{ "id": "text", "kind": "text", "maxBytes": 1024 }],
|
||||
"artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "text", "production": "runner-generated" }],
|
||||
"gates": [
|
||||
{ "id": "FE-GATE-001", "name": "one", "commandIds": ["command"], "logArtifactId": "log", "evidenceArtifactIds": ["log"], "retentionClassId": "merge" },
|
||||
{ "id": "FE-GATE-001", "name": "duplicate", "commandIds": ["command"], "logArtifactId": "log", "evidenceArtifactIds": ["log"], "retentionClassId": "merge" }
|
||||
],
|
||||
"stages": [],
|
||||
"jobs": [],
|
||||
"retention": { "durationStatus": "UNSUPPORTED", "classes": [{ "id": "merge", "policy": "one cycle" }] }
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"jobId": "merge_gate",
|
||||
"needs": ["release_gate"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"providerAdapter": ".gitea/workflows/quality-gates.yml",
|
||||
"commands": [],
|
||||
"artifactSchemas": [],
|
||||
"artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "missing", "production": "runner-generated" }],
|
||||
"gates": [],
|
||||
"stages": [],
|
||||
"jobs": [],
|
||||
"retention": { "durationStatus": "UNSUPPORTED", "classes": [] }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"jobId": "release_gate",
|
||||
"addGateId": "FE-GATE-001"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"providerAdapter": ".gitea/workflows/quality-gates.yml",
|
||||
"commands": [],
|
||||
"artifactSchemas": [],
|
||||
"artifacts": [],
|
||||
"gates": [],
|
||||
"stages": [],
|
||||
"jobs": [],
|
||||
"retention": { "durationStatus": "UNSUPPORTED", "classes": [] },
|
||||
"unexpected": true
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"jobId": "merge_gate",
|
||||
"needs": ["missing"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"jobId": "merge_gate",
|
||||
"removeGateId": "FE-GATE-001"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"total": {
|
||||
"lines": { "pct": 0 },
|
||||
"statements": { "pct": 0 },
|
||||
"functions": { "pct": 0 },
|
||||
"branches": { "pct": 0 }
|
||||
},
|
||||
"src/adapters/http/http-execution-v3.ts": {
|
||||
"lines": { "pct": 0 },
|
||||
"statements": { "pct": 0 },
|
||||
"functions": { "pct": 0 },
|
||||
"branches": { "pct": 0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"total": {
|
||||
"lines": { "total": 14, "covered": 14, "skipped": 0, "pct": 100 },
|
||||
"statements": { "total": 14, "covered": 14, "skipped": 0, "pct": 100 },
|
||||
"functions": { "total": 14, "covered": 14, "skipped": 0, "pct": 100 },
|
||||
"branches": { "total": 14, "covered": 14, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/adapters/http/bounded-body-reader.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/adapters/http/bounded-json.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/adapters/http/http-execution-v3.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/adapters/http/request-builder.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/adapters/http/retry-policy.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/adapters/query-cache/server-state-scope-runtime.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/adapters/service-worker/service-worker-lifecycle.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/adapters/storage/browser-storage-adapter.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/adapters/telemetry/best-effort-telemetry.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/application/create-application.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/application/policies/compatibility.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/application/policies/performance-budgets.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/application/policies/promotion-readiness.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
},
|
||||
"src/application/use-cases/decide-chunk-recovery.ts": {
|
||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user