feat: harden test and registry evidence
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
|
||||
|
||||
for (const route of Object.values(ROUTE_REGISTRY).map((definition) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
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";
|
||||
|
||||
test("boots the public app shell", async ({ page }) => {
|
||||
@@ -32,6 +33,19 @@ test("opens the protected integration route through the local demo seam", async
|
||||
(definition) => definition.access === "integration-defined",
|
||||
);
|
||||
if (!protectedRoute) throw new Error("An integration route is required");
|
||||
await page.route(
|
||||
"http://localhost:8080/api/reference-resources?*",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: successEnvelope([
|
||||
{
|
||||
id: "browser-reference",
|
||||
name: "Browser reference",
|
||||
createdAt: "2026-07-26T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
await page.goto(protectedRoute.path);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "세션이 필요합니다." }),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
|
||||
test("boots and navigates the compact production shell", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
const menu = page.getByRole("button", { name: "메뉴", exact: true });
|
||||
await expect(menu).toHaveCSS("min-width", "44px");
|
||||
await menu.click();
|
||||
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
await page.getByRole("link", { name: "UI 구성요소" }).click();
|
||||
await expect(page).toHaveURL(/\/examples\/ui$/);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-build-id", "local-build");
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - window.innerWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
|
||||
test("runs menu typeahead, tabs and duplicate toast interactions", async ({
|
||||
page,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
|
||||
test("switches the shell locale and keeps pseudo-locale copy within compact layout", async ({
|
||||
page,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
|
||||
/** @param {import("@playwright/test").Page} page */
|
||||
async function openReferenceForm(page) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
|
||||
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 "@playwright/test";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
|
||||
test("persists an explicit color scheme through the storage contract", async ({
|
||||
page,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.js";
|
||||
|
||||
test("validates and reports the common text-field flow", async ({ page }) => {
|
||||
await page.goto("/examples/ui");
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {
|
||||
@@ -16,6 +14,9 @@ import {
|
||||
|
||||
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";
|
||||
|
||||
const runtimeConfig = {
|
||||
APP_ENV: "local",
|
||||
@@ -56,41 +57,23 @@ const releaseManifest = {
|
||||
const listRequests = vi.fn();
|
||||
const createRequests = vi.fn();
|
||||
const resources = [{ id: "reference-1", name: "Existing" }];
|
||||
const server = setupServer(
|
||||
http.get("http://app.test/config.json", () =>
|
||||
HttpResponse.json(runtimeConfig),
|
||||
),
|
||||
http.get("http://app.test/release-manifest.json", () =>
|
||||
HttpResponse.json(releaseManifest),
|
||||
),
|
||||
http.get("https://api.test/api/reference-resources", ({ request }) => {
|
||||
listRequests(new URL(request.url).search);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: resources,
|
||||
meta: { requestId: "request-list", traceId: "trace-list" },
|
||||
});
|
||||
}),
|
||||
http.post("https://api.test/api/reference-resources", async ({ request }) => {
|
||||
const body = (await request.json()) as { name: string };
|
||||
createRequests(body);
|
||||
const created = { id: "reference-created", name: body.name };
|
||||
resources.push(created);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: created,
|
||||
meta: { requestId: "request-create", traceId: "trace-create" },
|
||||
});
|
||||
const mockApi = createStrictMockServer(
|
||||
...createBootstrapHandlers(runtimeConfig, releaseManifest),
|
||||
...createReferenceScenarioHandlers({
|
||||
resources,
|
||||
onList: listRequests,
|
||||
onCreate: createRequests,
|
||||
}),
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
beforeAll(mockApi.listen);
|
||||
afterEach(() => {
|
||||
mockApi.reset();
|
||||
listRequests.mockClear();
|
||||
createRequests.mockClear();
|
||||
resources.splice(1);
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
afterAll(mockApi.close);
|
||||
|
||||
const absoluteFetch: typeof fetch = (input, init) => {
|
||||
if (input instanceof Request) return fetch(input, init);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"cases": [
|
||||
{
|
||||
"id": "ordering-only",
|
||||
"expected": "none",
|
||||
"before": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": {
|
||||
"A": { "path": "/a", "tags": ["one", "two"] },
|
||||
"B": { "path": "/b", "tags": ["three"] }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"registries": [
|
||||
{
|
||||
"rows": {
|
||||
"B": { "tags": ["three"], "path": "/b" },
|
||||
"A": { "tags": ["two", "one"], "path": "/a" }
|
||||
},
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"registryId": "FE-REG-TEST"
|
||||
}
|
||||
],
|
||||
"schemaVersion": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row-addition",
|
||||
"expected": "additive",
|
||||
"before": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": { "A": { "path": "/a" } }
|
||||
}
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": {
|
||||
"A": { "path": "/a" },
|
||||
"B": { "path": "/b" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "behavior-change",
|
||||
"expected": "behavior-change",
|
||||
"before": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": { "A": { "path": "/a", "owner": "one" } }
|
||||
}
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": { "A": { "path": "/a", "owner": "two" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row-removal",
|
||||
"expected": "breaking",
|
||||
"before": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": {
|
||||
"A": { "path": "/a" },
|
||||
"B": { "path": "/b" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": { "A": { "path": "/a" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "field-type-narrowing",
|
||||
"expected": "breaking",
|
||||
"before": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": { "A": { "limit": 10 } }
|
||||
}
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": { "A": { "limit": "10" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "route-path-change",
|
||||
"expected": "breaking",
|
||||
"before": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": { "A": { "path": "/before" } }
|
||||
}
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": { "A": { "path": "/after" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "registry-contract-narrowing",
|
||||
"expected": "breaking",
|
||||
"before": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": {
|
||||
"breakingFields": ["path"],
|
||||
"allowedValues": { "kind": ["one", "two"] }
|
||||
},
|
||||
"rows": { "A": { "path": "/a", "kind": "one" } }
|
||||
}
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": {
|
||||
"breakingFields": ["path"],
|
||||
"allowedValues": { "kind": ["one"] }
|
||||
},
|
||||
"rows": { "A": { "path": "/a", "kind": "one" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"breakingEvidence": {
|
||||
"before": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": { "A": { "path": "/before" } }
|
||||
}
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"schemaVersion": 2,
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-TEST",
|
||||
"contract": { "breakingFields": ["path"] },
|
||||
"rows": { "A": { "path": "/after" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"tamperedApproval": {
|
||||
"snapshot": {
|
||||
"schemaVersion": 2,
|
||||
"registries": []
|
||||
},
|
||||
"approval": {
|
||||
"schemaVersion": 1,
|
||||
"snapshotDigest": "tampered",
|
||||
"owner": "fixture-owner",
|
||||
"approvedAt": "2026-07-26T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"snapshotDigest": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"owner": "negative-fixture",
|
||||
"approvedAt": "2026-07-26T00:00:00.000Z"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
test("false visual pass", async ({ page }) => {
|
||||
await expect(page).toHaveScreenshot({
|
||||
mask: [page.locator("body")]
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
test.skip("unowned indefinite quarantine", async () => {});
|
||||
@@ -0,0 +1,30 @@
|
||||
export function successEnvelope<Value>(
|
||||
data: Value,
|
||||
requestId = "fixture-request",
|
||||
) {
|
||||
return Object.freeze({
|
||||
success: true as const,
|
||||
data: structuredClone(data),
|
||||
meta: Object.freeze({
|
||||
requestId,
|
||||
traceId: "fixture-trace",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function failureEnvelope(
|
||||
code: string,
|
||||
details?: Readonly<Record<string, unknown>>,
|
||||
) {
|
||||
return Object.freeze({
|
||||
success: false as const,
|
||||
error: Object.freeze({
|
||||
code,
|
||||
...(details ? { details: structuredClone(details) } : {}),
|
||||
}),
|
||||
meta: Object.freeze({
|
||||
requestId: "fixture-request",
|
||||
traceId: "fixture-trace",
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
export function createBootstrapHandlers(
|
||||
runtimeConfig: Readonly<Record<string, unknown>>,
|
||||
releaseManifest: Readonly<Record<string, unknown>>,
|
||||
baseUrl = "http://app.test",
|
||||
) {
|
||||
return [
|
||||
http.get(`${baseUrl}/config.json`, () =>
|
||||
HttpResponse.json(structuredClone(runtimeConfig)),
|
||||
),
|
||||
http.get(`${baseUrl}/release-manifest.json`, () =>
|
||||
HttpResponse.json(structuredClone(releaseManifest)),
|
||||
),
|
||||
] as const;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
|
||||
import {
|
||||
failureEnvelope,
|
||||
successEnvelope,
|
||||
} from "../contracts/envelopes.js";
|
||||
import type { HttpScenarioId } from "../scenarios/catalog.js";
|
||||
import { assertOperationScenario } from "../scenarios/catalog.js";
|
||||
|
||||
export type ReferenceResourceFixture = Readonly<{
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt?: string;
|
||||
}>;
|
||||
|
||||
type ScenarioOptions = Readonly<{
|
||||
baseUrl?: string;
|
||||
scenarios?: Partial<
|
||||
Record<
|
||||
| "LIST_REFERENCE_RESOURCES"
|
||||
| "CREATE_REFERENCE_RESOURCE"
|
||||
| "GET_REFERENCE_RESOURCE",
|
||||
HttpScenarioId
|
||||
>
|
||||
>;
|
||||
resources?: ReferenceResourceFixture[];
|
||||
onList?(search: string): void;
|
||||
onCreate?(body: Readonly<Record<string, unknown>>): void;
|
||||
}>;
|
||||
|
||||
const DEFAULT_RESOURCE = Object.freeze({
|
||||
id: "reference-1",
|
||||
name: "Reference",
|
||||
createdAt: "2026-07-26T00:00:00.000Z",
|
||||
});
|
||||
|
||||
async function scenarioResponse(
|
||||
scenario: HttpScenarioId,
|
||||
payload: unknown,
|
||||
attempt: number,
|
||||
) {
|
||||
if (scenario === "slow") await delay(50);
|
||||
if (scenario === "timeout") await delay(30_000);
|
||||
if (scenario === "network-error") return HttpResponse.error();
|
||||
if (scenario === "content-type-mismatch") {
|
||||
return new HttpResponse("<html>not json</html>", {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
}
|
||||
if (scenario === "malformed-json") {
|
||||
return new HttpResponse("{invalid", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (scenario === "envelope-mismatch") {
|
||||
return HttpResponse.json({ data: payload });
|
||||
}
|
||||
if (scenario === "schema-mismatch") {
|
||||
return HttpResponse.json(successEnvelope({ unexpected: true }));
|
||||
}
|
||||
if (
|
||||
scenario === "auth-persistent-401" ||
|
||||
(scenario === "auth-recover-once" && attempt === 1)
|
||||
) {
|
||||
return HttpResponse.json(failureEnvelope("AUTH_REQUIRED"), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
if (scenario === "forbidden-403") {
|
||||
return HttpResponse.json(failureEnvelope("FORBIDDEN"), { status: 403 });
|
||||
}
|
||||
if (scenario === "not-found-404") {
|
||||
return HttpResponse.json(failureEnvelope("NOT_FOUND"), { status: 404 });
|
||||
}
|
||||
if (scenario === "conflict-409") {
|
||||
return HttpResponse.json(failureEnvelope("CONFLICT"), { status: 409 });
|
||||
}
|
||||
if (scenario === "validation-422") {
|
||||
return HttpResponse.json(
|
||||
failureEnvelope("VALIDATION_REJECTED", {
|
||||
issues: [{ path: "name", code: "too_small" }],
|
||||
}),
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
if (scenario === "rate-limited-429") {
|
||||
return HttpResponse.json(failureEnvelope("RATE_LIMITED"), {
|
||||
status: 429,
|
||||
headers: { "Retry-After": "1" },
|
||||
});
|
||||
}
|
||||
if (
|
||||
scenario === "server-terminal-500" ||
|
||||
(scenario === "server-retry-success" && attempt === 1)
|
||||
) {
|
||||
return HttpResponse.json(failureEnvelope("SERVER_FAILURE"), {
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
return HttpResponse.json(successEnvelope(payload));
|
||||
}
|
||||
|
||||
export function createReferenceScenarioHandlers(options: ScenarioOptions = {}) {
|
||||
const baseUrl = options.baseUrl ?? "https://api.test";
|
||||
const resources = options.resources ?? [{ ...DEFAULT_RESOURCE }];
|
||||
const attempts = new Map<string, number>();
|
||||
const scenarioFor = (
|
||||
operationId: keyof NonNullable<ScenarioOptions["scenarios"]>,
|
||||
) =>
|
||||
assertOperationScenario(
|
||||
operationId,
|
||||
options.scenarios?.[operationId] ?? "success",
|
||||
);
|
||||
const nextAttempt = (operationId: string) => {
|
||||
const next = (attempts.get(operationId) ?? 0) + 1;
|
||||
attempts.set(operationId, next);
|
||||
return next;
|
||||
};
|
||||
|
||||
return [
|
||||
http.get(`${baseUrl}/api/reference-resources`, ({ request }) => {
|
||||
options.onList?.(new URL(request.url).search);
|
||||
const scenario = scenarioFor("LIST_REFERENCE_RESOURCES");
|
||||
const payload = scenario === "empty" ? [] : resources;
|
||||
return scenarioResponse(
|
||||
scenario,
|
||||
payload,
|
||||
nextAttempt("LIST_REFERENCE_RESOURCES"),
|
||||
);
|
||||
}),
|
||||
http.post(
|
||||
`${baseUrl}/api/reference-resources`,
|
||||
async ({ request }) => {
|
||||
const rawBody = await request.json();
|
||||
const body =
|
||||
rawBody &&
|
||||
typeof rawBody === "object" &&
|
||||
!Array.isArray(rawBody)
|
||||
? (rawBody as Readonly<Record<string, unknown>>)
|
||||
: {};
|
||||
options.onCreate?.(body);
|
||||
const scenario = scenarioFor("CREATE_REFERENCE_RESOURCE");
|
||||
const created = {
|
||||
id: "reference-created",
|
||||
name: String(body.name ?? "Created"),
|
||||
createdAt: "2026-07-26T00:00:00.000Z",
|
||||
};
|
||||
if (scenario === "success") resources.push(created);
|
||||
return scenarioResponse(
|
||||
scenario,
|
||||
created,
|
||||
nextAttempt("CREATE_REFERENCE_RESOURCE"),
|
||||
);
|
||||
},
|
||||
),
|
||||
http.get(
|
||||
`${baseUrl}/api/reference-resources/:resourceId`,
|
||||
({ params }) => {
|
||||
const scenario = scenarioFor("GET_REFERENCE_RESOURCE");
|
||||
const resource =
|
||||
resources.find((entry) => entry.id === params.resourceId) ??
|
||||
DEFAULT_RESOURCE;
|
||||
return scenarioResponse(
|
||||
scenario,
|
||||
resource,
|
||||
nextAttempt("GET_REFERENCE_RESOURCE"),
|
||||
);
|
||||
},
|
||||
),
|
||||
] as const;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export const HTTP_SCENARIO_IDS = Object.freeze([
|
||||
"success",
|
||||
"empty",
|
||||
"slow",
|
||||
"network-error",
|
||||
"timeout",
|
||||
"aborted",
|
||||
"content-type-mismatch",
|
||||
"malformed-json",
|
||||
"envelope-mismatch",
|
||||
"schema-mismatch",
|
||||
"auth-recover-once",
|
||||
"auth-persistent-401",
|
||||
"forbidden-403",
|
||||
"not-found-404",
|
||||
"conflict-409",
|
||||
"validation-422",
|
||||
"rate-limited-429",
|
||||
"server-retry-success",
|
||||
"server-terminal-500",
|
||||
] as const);
|
||||
|
||||
export type HttpScenarioId = (typeof HTTP_SCENARIO_IDS)[number];
|
||||
|
||||
export const OPERATION_SCENARIO_CATALOG = Object.freeze({
|
||||
LIST_REFERENCE_RESOURCES: HTTP_SCENARIO_IDS,
|
||||
CREATE_REFERENCE_RESOURCE: Object.freeze(
|
||||
HTTP_SCENARIO_IDS.filter(
|
||||
(scenario) => !["empty", "aborted"].includes(scenario),
|
||||
),
|
||||
),
|
||||
GET_REFERENCE_RESOURCE: HTTP_SCENARIO_IDS,
|
||||
} satisfies Readonly<Record<string, readonly HttpScenarioId[]>>);
|
||||
|
||||
export function assertOperationScenario(
|
||||
operationId: keyof typeof OPERATION_SCENARIO_CATALOG,
|
||||
scenario: HttpScenarioId,
|
||||
) {
|
||||
if (!OPERATION_SCENARIO_CATALOG[operationId].includes(scenario)) {
|
||||
throw new Error(
|
||||
`Scenario ${scenario} is not declared for ${operationId}`,
|
||||
);
|
||||
}
|
||||
return scenario;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { setupServer } from "msw/node";
|
||||
|
||||
export function createStrictMockServer(
|
||||
...handlers: Parameters<typeof setupServer>
|
||||
) {
|
||||
const server = setupServer(...handlers);
|
||||
return Object.freeze({
|
||||
server,
|
||||
listen: () => server.listen({ onUnhandledRequest: "error" }),
|
||||
reset: () => server.resetHandlers(),
|
||||
close: () => server.close(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
} from "../support/browser/strict-browser-test.js";
|
||||
|
||||
test("runs the isolated overlay interaction with blocking accessibility", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(
|
||||
"/iframe.html?id=platform-design-system--overlay-interaction&viewMode=story",
|
||||
);
|
||||
const trigger = page.getByRole("button", { name: "Open dialog" });
|
||||
await expect(trigger).toBeVisible();
|
||||
await trigger.click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Confirm platform action" }),
|
||||
).toBeVisible();
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
expect,
|
||||
test as base,
|
||||
type ConsoleMessage,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
function ignoredConsole(message: ConsoleMessage) {
|
||||
return (
|
||||
message.type() === "warning" &&
|
||||
message.text().includes("NO_COLOR")
|
||||
);
|
||||
}
|
||||
|
||||
export const test = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
const failures: string[] = [];
|
||||
const onConsole = (message: ConsoleMessage) => {
|
||||
if (
|
||||
["error", "warning"].includes(message.type()) &&
|
||||
!ignoredConsole(message)
|
||||
) {
|
||||
failures.push(`console:${message.type()}:${message.text()}`);
|
||||
}
|
||||
};
|
||||
const onPageError = (error: Error) => {
|
||||
failures.push(`pageerror:${error.name}`);
|
||||
};
|
||||
const onRequestFailed = (request: import("@playwright/test").Request) => {
|
||||
failures.push(
|
||||
`requestfailed:${request.method()}:${new URL(request.url()).pathname}:${request.failure()?.errorText ?? "unknown"}`,
|
||||
);
|
||||
};
|
||||
page.on("console", onConsole);
|
||||
page.on("pageerror", onPageError);
|
||||
page.on("requestfailed", onRequestFailed);
|
||||
await use(page);
|
||||
page.off("console", onConsole);
|
||||
page.off("pageerror", onPageError);
|
||||
page.off("requestfailed", onRequestFailed);
|
||||
expect(failures, "unexpected browser console/page errors").toEqual([]);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
export type { Page };
|
||||
@@ -0,0 +1,69 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function createDeterministicClock(start = 0) {
|
||||
let current = start;
|
||||
return Object.freeze({
|
||||
now: () => current,
|
||||
sleep: async (milliseconds: number, signal?: AbortSignal) => {
|
||||
if (signal?.aborted) throw new DOMException("aborted", "AbortError");
|
||||
current += Math.max(0, milliseconds);
|
||||
},
|
||||
advance(milliseconds: number) {
|
||||
current += Math.max(0, milliseconds);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createSeededRandom(seed = 1) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state * 1_664_525 + 1_013_904_223) >>> 0;
|
||||
return state / 0x1_0000_0000;
|
||||
};
|
||||
}
|
||||
|
||||
export function createControlledScheduler() {
|
||||
const callbacks: Array<() => void> = [];
|
||||
return Object.freeze({
|
||||
callbacks,
|
||||
setTimeout: vi.fn((callback: () => void) => {
|
||||
callbacks.push(callback);
|
||||
return callbacks.length - 1;
|
||||
}),
|
||||
clearTimeout: vi.fn(),
|
||||
runNext() {
|
||||
callbacks.shift()?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createRecordingStorage() {
|
||||
const values = new Map<string, string>();
|
||||
const operations: Array<
|
||||
Readonly<{ operation: "get" | "set" | "remove"; key: string }>
|
||||
> = [];
|
||||
const storage: Storage = {
|
||||
getItem(key) {
|
||||
operations.push({ operation: "get", key });
|
||||
return values.get(key) ?? null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
operations.push({ operation: "set", key });
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem(key) {
|
||||
operations.push({ operation: "remove", key });
|
||||
values.delete(key);
|
||||
},
|
||||
clear() {
|
||||
values.clear();
|
||||
},
|
||||
key(index) {
|
||||
return [...values.keys()][index] ?? null;
|
||||
},
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
};
|
||||
return Object.freeze({ storage, operations, values });
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
canonicalRegistryJson,
|
||||
diffRegistrySnapshots,
|
||||
registrySnapshotDigest,
|
||||
validateBreakingEvidence,
|
||||
verifyRegistryBaselineApproval,
|
||||
} from "../../scripts/lib/registry-compatibility.mjs";
|
||||
|
||||
function snapshot(
|
||||
rows: Readonly<Record<string, Readonly<Record<string, unknown>>>>,
|
||||
) {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
registries: [
|
||||
{
|
||||
registryId: "FE-REG-TEST",
|
||||
contract: { breakingFields: ["path"] },
|
||||
rows,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("registry compatibility evidence", () => {
|
||||
it("canonicalizes object and primitive-array ordering", () => {
|
||||
expect(
|
||||
canonicalRegistryJson({
|
||||
second: ["b", "a"],
|
||||
first: { z: 1, a: 2 },
|
||||
}),
|
||||
).toBe('{"first":{"a":2,"z":1},"second":["a","b"]}');
|
||||
});
|
||||
|
||||
it("classifies additive, behavioral and breaking actual diffs", () => {
|
||||
expect(
|
||||
diffRegistrySnapshots(
|
||||
snapshot({ A: { path: "/a" } }),
|
||||
snapshot({ A: { path: "/a" }, B: { path: "/b" } }),
|
||||
).impact,
|
||||
).toBe("additive");
|
||||
expect(
|
||||
diffRegistrySnapshots(
|
||||
snapshot({ A: { path: "/a", owner: "one" } }),
|
||||
snapshot({ A: { path: "/a", owner: "two" } }),
|
||||
).impact,
|
||||
).toBe("behavior-change");
|
||||
expect(
|
||||
diffRegistrySnapshots(
|
||||
snapshot({ A: { path: "/a" } }),
|
||||
snapshot({ A: { path: "/moved" } }),
|
||||
).impact,
|
||||
).toBe("breaking");
|
||||
expect(
|
||||
diffRegistrySnapshots(
|
||||
snapshot({ A: { path: "/a" } }),
|
||||
snapshot({}),
|
||||
).impact,
|
||||
).toBe("breaking");
|
||||
});
|
||||
|
||||
it("verifies the approved digest and complete breaking evidence", () => {
|
||||
const before = snapshot({ A: { path: "/before" } });
|
||||
const after = snapshot({ A: { path: "/after" } });
|
||||
const digest = registrySnapshotDigest(before);
|
||||
expect(
|
||||
verifyRegistryBaselineApproval(before, {
|
||||
schemaVersion: 1,
|
||||
snapshotDigest: digest,
|
||||
owner: "platform",
|
||||
approvedAt: "2026-07-26T00:00:00.000Z",
|
||||
}).passed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
verifyRegistryBaselineApproval(before, {
|
||||
schemaVersion: 1,
|
||||
snapshotDigest: "tampered",
|
||||
owner: "platform",
|
||||
approvedAt: "2026-07-26T00:00:00.000Z",
|
||||
}).passed,
|
||||
).toBe(false);
|
||||
|
||||
const diff = diffRegistrySnapshots(before, after);
|
||||
expect(validateBreakingEvidence(diff, { changes: [] }).passed).toBe(false);
|
||||
const changeId = diff.changes[0]?.changeId;
|
||||
expect(
|
||||
validateBreakingEvidence(diff, {
|
||||
changes: [
|
||||
{
|
||||
changeId,
|
||||
versionBump: "2",
|
||||
migration: "dual-read",
|
||||
compatibilityWindow: "one release",
|
||||
rollback: "restore previous registry snapshot",
|
||||
owner: "platform",
|
||||
},
|
||||
],
|
||||
}).passed,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,24 +2,32 @@ import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("registry governance manifest", () => {
|
||||
it("declares exactly nine single-owner registries and impact labels", async () => {
|
||||
it("declares ten typed, single-owner executable registries", async () => {
|
||||
const governance = JSON.parse(
|
||||
await readFile("config/contracts/registry-governance.json", "utf8"),
|
||||
);
|
||||
const registries =
|
||||
/** @type {Array<{registryId: string, owner: string}>} */ (
|
||||
/** @type {Array<{
|
||||
* registryId: string,
|
||||
* owner: string,
|
||||
* requiredFields: string[],
|
||||
* fieldTypes: Record<string, string>
|
||||
* }>} */ (
|
||||
governance.registries
|
||||
);
|
||||
expect(governance.registries).toHaveLength(9);
|
||||
expect(governance.registries).toHaveLength(10);
|
||||
expect(new Set(registries.map((entry) => entry.registryId)).size).toBe(
|
||||
9,
|
||||
10,
|
||||
);
|
||||
expect(registries.every((entry) => entry.owner)).toBe(true);
|
||||
expect(governance.compatibilityImpact.allowed).toEqual([
|
||||
"none",
|
||||
"additive",
|
||||
"behavior-change",
|
||||
"breaking",
|
||||
]);
|
||||
expect(
|
||||
registries.every(
|
||||
(entry) =>
|
||||
Array.isArray(entry.requiredFields) &&
|
||||
entry.requiredFields.length > 0 &&
|
||||
entry.fieldTypes &&
|
||||
Object.keys(entry.fieldTypes).length > 0,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
} from "../support/browser/strict-browser-test.js";
|
||||
|
||||
test("wide application shell visual contract", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
await expect(page).toHaveScreenshot("app-shell-wide-light.png", {
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("compact drawer and pseudo-locale visual contract", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/");
|
||||
await page.locator("#locale-preference").selectOption("en-XA");
|
||||
await page.locator(".app-shell__menu-button").click();
|
||||
await expect(page.locator(".ui-drawer")).toHaveJSProperty("open", true);
|
||||
await expect(page).toHaveScreenshot("app-shell-compact-pseudo-drawer.png", {
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("design-system gallery dark visual contract", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto("/examples/ui");
|
||||
await page.locator("#theme-preference").selectOption("dark");
|
||||
await expect(page.getByRole("main")).toHaveScreenshot(
|
||||
"design-system-gallery-dark.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("loading empty error and access surfaces visual contract", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 1100 });
|
||||
await page.goto("/examples/states");
|
||||
await expect(page.getByRole("main")).toHaveScreenshot(
|
||||
"state-surfaces-light.png",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user