feat: 기능 추가 과정중
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 = "reference-resources";
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -1,218 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
act,
|
||||
renderHook,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
useApplicationMutation,
|
||||
useApplicationQuery,
|
||||
} from "../../src/presentation/adapters/query/application-query.js";
|
||||
import { createFailure } from "../../src/contracts/errors.js";
|
||||
|
||||
function queryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: 0, gcTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {QueryClient} client */
|
||||
function wrapper(client) {
|
||||
/** @param {{children: React.ReactNode}} props */
|
||||
return function QueryWrapper({ children }) {
|
||||
return (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("application query inbound bridge", () => {
|
||||
it("latches a background failure over stale data and clears it on retry success", async () => {
|
||||
const client = queryClient();
|
||||
const responses = [
|
||||
{ ok: /** @type {const} */ (true), value: ["first"] },
|
||||
{
|
||||
ok: /** @type {const} */ (false),
|
||||
error: createFailure("SERVER_FAILURE", "LIST", 0),
|
||||
},
|
||||
{ ok: /** @type {const} */ (true), value: ["recovered"] },
|
||||
];
|
||||
const execute = vi.fn(async () => responses.shift() ?? responses[0]);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["resource", "list"],
|
||||
execute,
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(hook.result.current.data).toEqual(["first"]));
|
||||
await act(() => hook.result.current.retry());
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.indicator).toBe("stale-degraded"),
|
||||
);
|
||||
expect(hook.result.current.state.base).toBe("success");
|
||||
expect(hook.result.current.data).toEqual(["first"]);
|
||||
|
||||
await act(() => hook.result.current.retry());
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.data).toEqual(["recovered"]),
|
||||
);
|
||||
expect(hook.result.current.state.indicator).toBeNull();
|
||||
expect(execute).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("projects an initial application failure into terminal state", async () => {
|
||||
const client = queryClient();
|
||||
const failure = createFailure("FORBIDDEN", "LIST", 0);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["forbidden"],
|
||||
execute: async () => ({ ok: false, error: failure }),
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.base).toBe("terminal-error"),
|
||||
);
|
||||
expect(hook.result.current.state.failure).toBe(failure);
|
||||
});
|
||||
|
||||
it("passes cancellation to the application and does not retain an unmounted error", async () => {
|
||||
const client = queryClient();
|
||||
let aborted = false;
|
||||
const execute = vi.fn(
|
||||
({ signal }) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
aborted = true;
|
||||
resolve({
|
||||
ok: false,
|
||||
error: createFailure("REQUEST_ABORTED", "LIST", 0),
|
||||
});
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["cancelled"],
|
||||
execute,
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
|
||||
hook.unmount();
|
||||
await waitFor(() => expect(aborted).toBe(true));
|
||||
expect(client.getQueryState(["cancelled"])?.status).not.toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("application mutation inbound bridge", () => {
|
||||
it("deduplicates submit and commits one optimistic mutation", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "list"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
/** @type {(value: {ok: true, value: string}) => void} */
|
||||
let complete = () => {};
|
||||
const execute = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation({
|
||||
execute,
|
||||
invalidate: [["resource"]],
|
||||
currentData: client.getQueryData(key),
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (previous, input) => [
|
||||
.../** @type {string[]} */ (previous),
|
||||
input,
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
/** @type {ReturnType<typeof hook.result.current.submit> | null} */
|
||||
let first = null;
|
||||
/** @type {ReturnType<typeof hook.result.current.submit> | null} */
|
||||
let duplicate = null;
|
||||
act(() => {
|
||||
first = hook.result.current.submit("created");
|
||||
duplicate = hook.result.current.submit("created");
|
||||
});
|
||||
expect(first).toBe(duplicate);
|
||||
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
|
||||
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.indicator).toBe("mutation-pending"),
|
||||
);
|
||||
|
||||
complete({ ok: true, value: "created" });
|
||||
if (!first) throw new Error("expected pending mutation");
|
||||
await act(() => first);
|
||||
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.indicator).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("rolls optimistic data back and exposes a resolvable conflict", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "list"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
const conflict = createFailure("CONFLICT", "CREATE", 0);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation({
|
||||
execute: async () => ({ ok: false, error: conflict }),
|
||||
invalidate: [["resource"]],
|
||||
currentData: client.getQueryData(key),
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (previous, input) => [
|
||||
.../** @type {string[]} */ (previous),
|
||||
input,
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await hook.result.current.submit("conflicting");
|
||||
});
|
||||
expect(outcome).toEqual({ ok: false, error: conflict });
|
||||
expect(client.getQueryData(key)).toEqual(["existing"]);
|
||||
expect(hook.result.current.state.indicator).toBe("mutation-conflict");
|
||||
expect(hook.result.current.state.overlay).toMatchObject({
|
||||
mutationPending: false,
|
||||
mutationConflict: true,
|
||||
});
|
||||
|
||||
await act(() => hook.result.current.resolveConflict());
|
||||
expect(hook.result.current.state.indicator).toBeNull();
|
||||
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
act,
|
||||
renderHook,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type ApplicationResult,
|
||||
useApplicationMutation,
|
||||
useApplicationQuery,
|
||||
} from "../../src/presentation/adapters/query/application-query.ts";
|
||||
import { QueryInvalidationProvider } from "../../src/presentation/adapters/query/query-invalidation-provider.tsx";
|
||||
import {
|
||||
defineQueryInvalidationTopic,
|
||||
type QueryInvalidationCoordinator,
|
||||
} from "../../src/contracts/query-invalidation.ts";
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
|
||||
const RESOURCE_INVALIDATION_TOPIC =
|
||||
defineQueryInvalidationTopic("resource");
|
||||
|
||||
function queryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: 0, gcTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function wrapper(client: QueryClient) {
|
||||
const coordinator: QueryInvalidationCoordinator = {
|
||||
async invalidate(topics) {
|
||||
for (const topic of topics) {
|
||||
await client.invalidateQueries({
|
||||
queryKey: [topic],
|
||||
exact: false,
|
||||
refetchType: "active",
|
||||
});
|
||||
}
|
||||
},
|
||||
beginMutation() {
|
||||
return { release: async () => {} };
|
||||
},
|
||||
async resetLocal() {
|
||||
await client.cancelQueries();
|
||||
client.clear();
|
||||
},
|
||||
dispose() {},
|
||||
};
|
||||
return function QueryWrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<QueryInvalidationProvider coordinator={coordinator}>
|
||||
{children}
|
||||
</QueryInvalidationProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("application query inbound bridge", () => {
|
||||
it("latches a background failure over stale data and clears it on retry success", async () => {
|
||||
const client = queryClient();
|
||||
const responses: ApplicationResult<string[]>[] = [
|
||||
{ ok: true, value: ["first"] },
|
||||
{
|
||||
ok: false,
|
||||
error: createFailure("SERVER_FAILURE", "LIST", 0),
|
||||
},
|
||||
{ ok: true, value: ["recovered"] },
|
||||
];
|
||||
const execute = vi.fn(async () => responses.shift() ?? responses[0]);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["resource", "list"],
|
||||
execute,
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(hook.result.current.data).toEqual(["first"]));
|
||||
await act(() => hook.result.current.retry());
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.indicator).toBe("stale-degraded"),
|
||||
);
|
||||
expect(hook.result.current.state.base).toBe("success");
|
||||
expect(hook.result.current.data).toEqual(["first"]);
|
||||
|
||||
await act(() => hook.result.current.retry());
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.data).toEqual(["recovered"]),
|
||||
);
|
||||
expect(hook.result.current.state.indicator).toBeNull();
|
||||
expect(execute).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("projects an initial application failure into terminal state", async () => {
|
||||
const client = queryClient();
|
||||
const failure = createFailure("FORBIDDEN", "LIST", 0);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["forbidden"],
|
||||
execute: async () => ({ ok: false, error: failure }),
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.base).toBe("terminal-error"),
|
||||
);
|
||||
expect(hook.result.current.state.failure).toBe(failure);
|
||||
});
|
||||
|
||||
it("normalizes an unexpected execute rejection into a safe terminal failure", async () => {
|
||||
const client = queryClient();
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["unexpected-rejection"],
|
||||
execute: async () => {
|
||||
throw new Error("private upstream detail");
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.base).toBe("terminal-error"),
|
||||
);
|
||||
expect(hook.result.current.state.failure).toMatchObject({
|
||||
kind: "UNKNOWN_FAILURE",
|
||||
operationId: "APPLICATION_QUERY",
|
||||
userMessageKey: "error.unknown_failure",
|
||||
action: "contact-support",
|
||||
});
|
||||
expect(JSON.stringify(hook.result.current.state.failure)).not.toContain(
|
||||
"private upstream detail",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes cancellation to the application and does not retain an unmounted error", async () => {
|
||||
const client = queryClient();
|
||||
let aborted = false;
|
||||
const execute = vi.fn(
|
||||
({ signal }: { signal: AbortSignal }) =>
|
||||
new Promise<ApplicationResult<unknown>>((resolve) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
aborted = true;
|
||||
resolve({
|
||||
ok: false,
|
||||
error: createFailure("REQUEST_ABORTED", "LIST", 0),
|
||||
});
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["cancelled"],
|
||||
execute,
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
|
||||
hook.unmount();
|
||||
await waitFor(() => expect(aborted).toBe(true));
|
||||
expect(client.getQueryState(["cancelled"])?.status).not.toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("application mutation inbound bridge", () => {
|
||||
it("deduplicates submit and commits one optimistic mutation", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "list"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
let complete: (value: ApplicationResult<string>) => void = () => {};
|
||||
const execute = vi.fn(
|
||||
() =>
|
||||
new Promise<ApplicationResult<string>>((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute,
|
||||
invalidate: [RESOURCE_INVALIDATION_TOPIC],
|
||||
currentData: client.getQueryData(key),
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (previous, input) => [
|
||||
...(previous as string[]),
|
||||
input,
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let first: Promise<ApplicationResult<string>> | null = null;
|
||||
let duplicate: Promise<ApplicationResult<string>> | null = null;
|
||||
act(() => {
|
||||
first = hook.result.current.submit("created");
|
||||
duplicate = hook.result.current.submit("created");
|
||||
});
|
||||
expect(first).toBe(duplicate);
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryData(key)).toEqual(["existing", "created"]),
|
||||
);
|
||||
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.indicator).toBe("mutation-pending"),
|
||||
);
|
||||
|
||||
complete({ ok: true, value: "created" });
|
||||
if (!first) throw new Error("expected pending mutation");
|
||||
await act(() => first);
|
||||
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.indicator).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("never joins distinct mutation inputs to the same runtime promise", async () => {
|
||||
const client = queryClient();
|
||||
const resolvers = new Map<
|
||||
string,
|
||||
(value: ApplicationResult<string>) => void
|
||||
>();
|
||||
const execute = vi.fn(
|
||||
(input: string) =>
|
||||
new Promise<ApplicationResult<string>>((resolve) => {
|
||||
resolvers.set(input, resolve);
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute,
|
||||
currentData: true,
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let first: Promise<ApplicationResult<string>> | undefined;
|
||||
let second: Promise<ApplicationResult<string>> | undefined;
|
||||
act(() => {
|
||||
first = hook.result.current.submit("first");
|
||||
second = hook.result.current.submit("second");
|
||||
});
|
||||
expect(first).not.toBe(second);
|
||||
await waitFor(() => expect(execute).toHaveBeenCalledTimes(2));
|
||||
|
||||
resolvers.get("first")?.({ ok: true, value: "first" });
|
||||
resolvers.get("second")?.({ ok: true, value: "second" });
|
||||
if (!first || !second) throw new Error("expected pending mutations");
|
||||
await act(async () => {
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels an in-flight query before taking the optimistic snapshot", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "ordered-update"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
let finishCancellation: () => void = () => {};
|
||||
const cancellation = new Promise<void>((resolve) => {
|
||||
finishCancellation = resolve;
|
||||
});
|
||||
const cancelQueries = vi
|
||||
.spyOn(client, "cancelQueries")
|
||||
.mockImplementation(async () => cancellation);
|
||||
const getQueryData = vi.spyOn(client, "getQueryData");
|
||||
const update = vi.fn((previous, input) => [
|
||||
...(previous as string[]),
|
||||
input,
|
||||
]);
|
||||
const execute = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
value: "created",
|
||||
}));
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute,
|
||||
currentData: ["existing"],
|
||||
optimistic: { queryKey: key, update },
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let pending: Promise<ApplicationResult<string>> | undefined;
|
||||
act(() => {
|
||||
pending = hook.result.current.submit("created");
|
||||
});
|
||||
expect(cancelQueries).toHaveBeenCalledWith({
|
||||
queryKey: key,
|
||||
exact: true,
|
||||
});
|
||||
expect(getQueryData).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
|
||||
finishCancellation();
|
||||
if (!pending) throw new Error("expected pending mutation");
|
||||
await act(() => pending);
|
||||
|
||||
expect(getQueryData).toHaveBeenCalledWith(key);
|
||||
expect(update).toHaveBeenCalledWith(["existing"], "created");
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
|
||||
});
|
||||
|
||||
it("keeps a committed optimistic update when invalidation fails", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "committed-update"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
vi.spyOn(client, "invalidateQueries").mockRejectedValue(
|
||||
new Error("cache refresh failed"),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute: async () => ({ ok: true, value: "created" }),
|
||||
invalidate: [RESOURCE_INVALIDATION_TOPIC],
|
||||
currentData: ["existing"],
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (previous, input) => [
|
||||
...(previous as string[]),
|
||||
input,
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await hook.result.current.submit("created");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ ok: true, value: "created" });
|
||||
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
|
||||
});
|
||||
|
||||
it("normalizes an optimistic preparation defect without running the command", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "invalid-optimistic-update"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
const execute = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
value: "created",
|
||||
}));
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute,
|
||||
currentData: ["existing"],
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: () => {
|
||||
throw new Error("private optimistic detail");
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await hook.result.current.submit("created");
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "UNKNOWN_FAILURE",
|
||||
operationId: "APPLICATION_MUTATION",
|
||||
userMessageKey: "error.unknown_failure",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(outcome)).not.toContain("private optimistic detail");
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(client.getQueryData(key)).toEqual(["existing"]);
|
||||
});
|
||||
|
||||
it("removes an optimistic cache entry when no prior data existed", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "new-optimistic-entry"];
|
||||
const failure = createFailure("SERVER_FAILURE", "CREATE", 0);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute: async () => ({ ok: false, error: failure }),
|
||||
currentData: true,
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (_previous, input) => [input],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await hook.result.current.submit("temporary");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ ok: false, error: failure });
|
||||
expect(client.getQueryData(key)).toBeUndefined();
|
||||
expect(client.getQueryState(key)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rolls optimistic data back and exposes a resolvable conflict", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "list"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
const conflict = createFailure("CONFLICT", "CREATE", 0);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute: async () => ({ ok: false, error: conflict }),
|
||||
invalidate: [RESOURCE_INVALIDATION_TOPIC],
|
||||
currentData: client.getQueryData(key),
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (previous, input) => [
|
||||
...(previous as string[]),
|
||||
input,
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await hook.result.current.submit("conflicting");
|
||||
});
|
||||
expect(outcome).toEqual({ ok: false, error: conflict });
|
||||
expect(client.getQueryData(key)).toEqual(["existing"]);
|
||||
expect(hook.result.current.state.indicator).toBe("mutation-conflict");
|
||||
expect(hook.result.current.state.overlay).toMatchObject({
|
||||
mutationPending: false,
|
||||
mutationConflict: true,
|
||||
});
|
||||
|
||||
await act(() => hook.result.current.resolveConflict());
|
||||
expect(hook.result.current.state.indicator).toBeNull();
|
||||
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,9 @@ 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.js";
|
||||
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
|
||||
import { createFailure } from "../../src/contracts/errors.js";
|
||||
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([
|
||||
@@ -106,6 +106,42 @@ describe("async UI state matrix", () => {
|
||||
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),
|
||||
+5
-4
@@ -3,10 +3,10 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SafeText } from "../../src/presentation/security/safe-text.jsx";
|
||||
import { assertSafeConfigNames } from "../../src/contracts/env.js";
|
||||
import { defineStorageKey } from "../../src/contracts/storage-keys.js";
|
||||
import { projectTelemetryEvent } from "../../src/contracts/telemetry.js";
|
||||
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", () => {
|
||||
@@ -31,6 +31,7 @@ describe("browser security boundary", () => {
|
||||
backend: "sessionStorage",
|
||||
classification: "sensitive-forbidden",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "none",
|
||||
ttl: "session",
|
||||
migration: "discard",
|
||||
quotaFallback: "feature-disable",
|
||||
@@ -6,8 +6,8 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ChunkRecoveryBoundary,
|
||||
isChunkLoadFailure,
|
||||
} from "../../src/presentation/boundaries/chunk-recovery-boundary.js";
|
||||
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx";
|
||||
} 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");
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
Tabs,
|
||||
ToastProvider,
|
||||
useToast,
|
||||
} from "../../src/presentation/design-system/index.js";
|
||||
} 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", () => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Button } from "../../src/presentation/components/ui/button.jsx";
|
||||
import { Card } from "../../src/presentation/components/ui/card.jsx";
|
||||
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", () => {
|
||||
@@ -7,8 +7,8 @@ import { createMemoryRouter, RouterProvider, useNavigate } from "react-router-do
|
||||
import { z } from "zod";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createFailure } from "../../src/contracts/errors.js";
|
||||
import { Button } from "../../src/presentation/components/ui/button.jsx";
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
import { Button } from "../../src/presentation/components/ui/button.ts";
|
||||
import {
|
||||
DirtyNavigationDialog,
|
||||
ErrorSummary,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
FormField,
|
||||
useAppForm,
|
||||
useDirtyNavigationGuard,
|
||||
} from "../../src/presentation/forms/index.js";
|
||||
} from "../../src/presentation/forms/index.ts";
|
||||
|
||||
type Values = Readonly<Record<"name" | "note", string>>;
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
Drawer,
|
||||
Pagination,
|
||||
Tabs,
|
||||
} from "../../src/presentation/design-system/index.js";
|
||||
} from "../../src/presentation/design-system/index.ts";
|
||||
import {
|
||||
LocaleProvider,
|
||||
useLocale,
|
||||
type SupportedLocale,
|
||||
} from "../../src/presentation/i18n/index.js";
|
||||
} from "../../src/presentation/i18n/index.ts";
|
||||
|
||||
function LocaleHarness() {
|
||||
const { direction, locale, message, setLocale } = useLocale();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
FormPage,
|
||||
StandardPage,
|
||||
StatusPage,
|
||||
} from "../../src/presentation/templates/index.js";
|
||||
} from "../../src/presentation/templates/index.ts";
|
||||
|
||||
describe("page template slot contracts", () => {
|
||||
it("renders StandardPage minimum and full landmarks with one h1", () => {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// @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.js";
|
||||
import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
|
||||
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
|
||||
import { BootErrorShell } from "../../src/presentation/boundaries/boot-error-shell.jsx";
|
||||
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx";
|
||||
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";
|
||||
|
||||
/** @returns {import("react").ReactNode} */
|
||||
function Defect() {
|
||||
function Defect(): ReactNode {
|
||||
throw new Error("raw render stack");
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { render, screen } 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.js";
|
||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.js";
|
||||
import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
|
||||
import { createTestApplication } from "../helpers/create-test-application.js";
|
||||
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(
|
||||
+2
-2
@@ -3,8 +3,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createRuntimeComposition } from "../../src/bootstrap/create-runtime-composition.js";
|
||||
import { RuntimeApplication } from "../../src/bootstrap/runtime-application.jsx";
|
||||
import { createRuntimeComposition } from "../../src/bootstrap/create-runtime-composition.ts";
|
||||
import { RuntimeApplication } from "../../src/bootstrap/runtime-application.tsx";
|
||||
|
||||
const runtimeConfig = {
|
||||
APP_ENV: "local",
|
||||
@@ -5,12 +5,12 @@ 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.jsx";
|
||||
import { Badge } from "../../src/presentation/components/ui/badge.jsx";
|
||||
import { Button } from "../../src/presentation/components/ui/button.jsx";
|
||||
import { Card } from "../../src/presentation/components/ui/card.jsx";
|
||||
import { Dialog } from "../../src/presentation/components/ui/dialog.jsx";
|
||||
import { TextField } from "../../src/presentation/components/ui/text-field.jsx";
|
||||
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", () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
|
||||
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";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("boots the public app shell", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("boots and navigates the compact production shell", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("runs menu typeahead, tabs and duplicate toast interactions", async ({
|
||||
page,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
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,
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
/** @param {import("@playwright/test").Page} page */
|
||||
async function openReferenceForm(page) {
|
||||
async function openReferenceForm(page: Page) {
|
||||
await page.goto("/examples/reference-resources/new");
|
||||
await page.getByRole("button", { name: "로그인 시작" }).click();
|
||||
await expect(
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
import { successEnvelope } from "../mocks/contracts/envelopes.js";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import { successEnvelope } from "../mocks/contracts/envelopes.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
test("opens the protected integration route through the local demo seam", async ({
|
||||
page,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("reflows the UI gallery at the 320px minimum without horizontal overflow", async ({
|
||||
page,
|
||||
@@ -1,5 +1,5 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("persists an explicit color scheme through the storage contract", async ({
|
||||
page,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
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");
|
||||
@@ -3,19 +3,22 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRouteUrl,
|
||||
parseRouteInput,
|
||||
} from "../../../src/presentation/routes/route-codecs.js";
|
||||
} from "../../../src/presentation/routes/route-codecs.ts";
|
||||
import {
|
||||
mapReferenceOperation,
|
||||
toReferenceView,
|
||||
} from "../../../src/features/reference-feature/contracts/reference-mapper.js";
|
||||
} from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
|
||||
import {
|
||||
validateReferencePayload,
|
||||
validateReferenceRequest,
|
||||
} from "../../../src/features/reference-feature/contracts/reference-schemas.js";
|
||||
} from "../../../src/features/reference-feature/contracts/reference-schemas.ts";
|
||||
import {
|
||||
REFERENCE_FEATURE_CONTRACT,
|
||||
REFERENCE_FEATURE_ID,
|
||||
referenceQueryKeys,
|
||||
} from "../../../src/features/reference-feature/contracts/reference-feature-contract.js";
|
||||
} 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";
|
||||
|
||||
describe("reference feature boundary contracts", () => {
|
||||
it("round-trips one canonical filter through URL and query identity", () => {
|
||||
@@ -53,11 +56,11 @@ describe("reference feature boundary contracts", () => {
|
||||
{ id: "unsafe", name: 42 },
|
||||
]),
|
||||
).toMatchObject({ success: false });
|
||||
expect(() =>
|
||||
expect(
|
||||
mapReferenceOperation("LIST_REFERENCE_RESOURCES", [
|
||||
{ id: "unsafe", name: 42 },
|
||||
]),
|
||||
).toThrow();
|
||||
).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("normalizes request input and maps only owned domain fields", () => {
|
||||
@@ -71,8 +74,10 @@ describe("reference feature boundary contracts", () => {
|
||||
name: "Example",
|
||||
createdAt: "2026-07-26T00:00:00.000Z",
|
||||
});
|
||||
if (!("id" in model)) throw new Error("expected one model");
|
||||
expect(toReferenceView(model)).toEqual({
|
||||
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",
|
||||
@@ -91,8 +96,45 @@ describe("reference feature boundary contracts", () => {
|
||||
"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",
|
||||
});
|
||||
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.queryRegistry)).toEqual([
|
||||
"REFERENCE_RESOURCE",
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createHttpClient } from "../../../src/adapters/http/client.js";
|
||||
import { createReferenceHttpGateway } from "../../../src/features/reference-feature/adapters/reference-http-gateway.js";
|
||||
import { createReferenceFeatureInput } from "../../../src/features/reference-feature/application/reference-feature-api.js";
|
||||
import { REFERENCE_FEATURE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract.js";
|
||||
import { mapReferenceOperation } from "../../../src/features/reference-feature/contracts/reference-mapper.js";
|
||||
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.js";
|
||||
} 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 () => {
|
||||
@@ -24,6 +27,7 @@ describe("reference feature diagnostics correlation", () => {
|
||||
>;
|
||||
const client = createHttpClient({
|
||||
baseUrl: "https://api.test",
|
||||
authSession: createDemoSessionAdapter("authenticated"),
|
||||
fetcher: async () =>
|
||||
Response.json({
|
||||
success: true,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,21 +8,27 @@ 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.js";
|
||||
import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
|
||||
import type { AuthSessionPort } from "../../../src/application/ports/auth-session-port.ts";
|
||||
import type {
|
||||
ReferenceFeatureInput,
|
||||
ReferenceResult,
|
||||
} from "../../../src/features/reference-feature/application/reference-feature-api.js";
|
||||
import { REFERENCE_FEATURE_ID } from "../../../src/features/reference-feature/contracts/reference-feature-contract.js";
|
||||
import type { ReferenceResourceView } from "../../../src/features/reference-feature/contracts/reference-mapper.js";
|
||||
import { createFailure } from "../../../src/contracts/errors.js";
|
||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.js";
|
||||
import { AppRouter } from "../../../src/presentation/routes/app-router.js";
|
||||
import { createTestApplication } from "../../helpers/create-test-application.js";
|
||||
} from "../../../src/features/reference-feature/application/reference-feature-api.ts";
|
||||
import { REFERENCE_FEATURE_ID } from "../../../src/features/reference-feature/contracts/reference-feature-contract.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 { 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({
|
||||
@@ -31,16 +37,34 @@ function renderReference(
|
||||
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,
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session: createDemoSessionAdapter("authenticated"),
|
||||
featureInputs: { [REFERENCE_FEATURE_ID]: input },
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>
|
||||
<ServerStateScopeProvider runtime={serverStateScope}>
|
||||
<QueryInvalidationProvider coordinator={invalidation}>
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session,
|
||||
featureInputs: { [REFERENCE_FEATURE_ID]: input },
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>
|
||||
</QueryInvalidationProvider>
|
||||
</ServerStateScopeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
@@ -120,6 +144,7 @@ describe("reference feature page states", () => {
|
||||
});
|
||||
|
||||
it("renders backend forbidden even when the client access hint allowed entry", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderReference(
|
||||
inputWith({
|
||||
listResources: async () => ({
|
||||
@@ -135,6 +160,71 @@ describe("reference feature page states", () => {
|
||||
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 () => {
|
||||
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { createRuntimeComposition } from "../../../src/bootstrap/create-runtime-composition.js";
|
||||
import { RuntimeApplication } from "../../../src/bootstrap/runtime-application.jsx";
|
||||
import { createBootstrapHandlers } from "../../mocks/handlers/bootstrap.js";
|
||||
import { createReferenceScenarioHandlers } from "../../mocks/handlers/reference-resources.js";
|
||||
import { createStrictMockServer } from "../../mocks/server.js";
|
||||
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",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { createRuntimeAdapters } from "../../../../src/bootstrap/runtime-adapters.js";
|
||||
import { createRuntimeAdapters } from "../../../../src/bootstrap/runtime-adapters.ts";
|
||||
|
||||
export const bootstrapFactory = createRuntimeAdapters;
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
import { createApplication } from "../../../../src/application/create-application.js";
|
||||
import { createApplication } from "../../../../src/application/create-application.ts";
|
||||
|
||||
export const applicationFactory = createApplication;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createApplication } from "../../../../src/application/create-application.js";
|
||||
import { createApplication } from "../../../../src/application/create-application.ts";
|
||||
|
||||
export const applicationFactory = createApplication;
|
||||
|
||||
|
||||
+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);
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import { createHttpClient } from "../../../../src/adapters/http/client.js";
|
||||
import { createHttpClient } from "../../../../src/adapters/http/client.ts";
|
||||
|
||||
export const leakedHttpFactory = createHttpClient;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import "../../../../src/adapters/http/client.js";
|
||||
|
||||
export const invalidEdge = true;
|
||||
@@ -0,0 +1,3 @@
|
||||
import "../../../../src/adapters/http/client.ts";
|
||||
|
||||
export const invalidEdge = true;
|
||||
@@ -1,4 +1,4 @@
|
||||
import "../../../../src/adapters/http/client.js";
|
||||
import "../../../../src/adapters/http/client.ts";
|
||||
|
||||
export function InvalidPresentationFixture() {
|
||||
return <p>invalid</p>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ApplicationOutputPorts } from "../../../../src/application/ports/out/application-output-ports.js";
|
||||
import type { ApplicationOutputPorts } from "../../../../src/application/ports/out/application-output-ports.ts";
|
||||
|
||||
export function OutputPortLeak(_props: ApplicationOutputPorts) {
|
||||
return <p>forbidden</p>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ApplicationApi } from "../../../../src/application/ports/in/application-api.js";
|
||||
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");
|
||||
}
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
import { Button } from "../../../../src/presentation/design-system/primitives/core.js";
|
||||
import { Button } from "../../../../src/presentation/design-system/primitives/core.tsx";
|
||||
|
||||
export const DeepImport = Button;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { OPTIONAL_RECIPE_RUNTIME_SENTINEL } from "../../../../../../recipes/frontend-capabilities/index.js";
|
||||
import { OPTIONAL_RECIPE_RUNTIME_SENTINEL } from "../../../../../../recipes/frontend-capabilities/index.ts";
|
||||
|
||||
export const recipeInProduction = OPTIONAL_RECIPE_RUNTIME_SENTINEL;
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { createIndexedDbRepository } from "../../../adapters/storage/indexeddb/indexeddb-repository.ts";
|
||||
|
||||
export const composedWithoutProjectSelection = createIndexedDbRepository;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export function createOwnedWebSocket(endpoint: string): WebSocket {
|
||||
return new WebSocket(endpoint, "realtime.v1");
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export async function readOwnedPushSubscription(
|
||||
registration: ServiceWorkerRegistration,
|
||||
): Promise<PushSubscription | null> {
|
||||
return registration.pushManager.getSubscription();
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { createWebSocketConnection } from "../../../adapters/realtime/websocket/websocket-connection.ts";
|
||||
|
||||
export const incorrectlyComposed = createWebSocketConnection;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export function createRouteOwnedSocket(endpoint: string): WebSocket {
|
||||
setInterval(() => undefined, 1_000);
|
||||
return new WebSocket(endpoint);
|
||||
}
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FIXTURE-SOURCE",
|
||||
"path": "tests/fixtures/registry/forbidden/invalid-registry.js",
|
||||
"path": "tests/fixtures/registry/forbidden/invalid-registry.ts",
|
||||
"exportName": "INVALID_REGISTRY",
|
||||
"owner": "fixture",
|
||||
"requiredFields": ["id", "target"],
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
{
|
||||
"registryId": "FIXTURE-TARGET",
|
||||
"path": "tests/fixtures/registry/forbidden/invalid-registry.js",
|
||||
"path": "tests/fixtures/registry/forbidden/invalid-registry.ts",
|
||||
"exportName": "TARGET_REGISTRY",
|
||||
"owner": "fixture",
|
||||
"requiredFields": ["id"],
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "ROUTES",
|
||||
"path": "tests/fixtures/registry/routes/invalid-routes.js",
|
||||
"path": "tests/fixtures/registry/routes/invalid-routes.ts",
|
||||
"exportName": "INVALID_ROUTES",
|
||||
"owner": "fixture",
|
||||
"requiredFields": [
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
{
|
||||
"registryId": "RUNTIME",
|
||||
"path": "tests/fixtures/registry/routes/invalid-routes.js",
|
||||
"path": "tests/fixtures/registry/routes/invalid-routes.ts",
|
||||
"exportName": "INVALID_RUNTIME",
|
||||
"owner": "fixture",
|
||||
"requiredFields": ["routeId", "moduleId", "paramsCodec", "searchCodec"],
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function Fixture({ value }: { value: string }) {
|
||||
return <span>{value}</span>;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function Fixture({ value }) {
|
||||
return <span>{value}</span>;
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export function attachScript(source) {
|
||||
export function attachScript(source: string) {
|
||||
const script = document.createElement("script");
|
||||
script.src = source;
|
||||
document.head.append(script);
|
||||
-1
@@ -1 +0,0 @@
|
||||
export const execute = (source) => eval(source);
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const execute = (source: string) => eval(source);
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
export function RawHtml({ value }) {
|
||||
export function RawHtml({ value }: { value: string }) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: value }} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user