test: prove TechLog UI migration parity
@@ -343,20 +343,12 @@ describe("generic application router", () => {
|
||||
expect(window.location.pathname).toBe("/projects");
|
||||
});
|
||||
|
||||
it("renders the Public shell and TechLog not-found route", async () => {
|
||||
it("renders the source-compatible plain TechLog not-found response", async () => {
|
||||
window.history.pushState({}, "", "/missing");
|
||||
renderRouter();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "404",
|
||||
level: 1,
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "This page could not be found." }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
expect(await screen.findByText("Not Found", { exact: true })).toBeVisible();
|
||||
expect(screen.queryByRole("navigation", { name: "주요 탐색" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Studio routes inside the persistent Studio layout", async () => {
|
||||
|
||||
@@ -1,58 +1,40 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
for (const route of Object.values(ROUTE_REGISTRY).map((definition) => {
|
||||
if (definition.path === "*") return "/not-found";
|
||||
return definition.path.replace(":resourceId", "reference-1");
|
||||
})) {
|
||||
test(`@a11y ${route} has no critical or serious axe violations`, async ({
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import {
|
||||
gotoTechLog,
|
||||
TECH_LOG_CANONICAL_ROUTES,
|
||||
} from "../support/browser/tech-log-fixtures.ts";
|
||||
|
||||
for (const route of TECH_LOG_CANONICAL_ROUTES) {
|
||||
test(`@a11y ${route.routeId} has no critical or serious Axe violations`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(route);
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
await gotoTechLog(page, route.path);
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
const blocking = results.violations.filter((violation) =>
|
||||
["critical", "serious"].includes(violation.impact ?? ""),
|
||||
);
|
||||
expect(blocking).toEqual([]);
|
||||
expect(
|
||||
results.violations.filter((violation) =>
|
||||
["critical", "serious"].includes(violation.impact ?? ""),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("@a11y keyboard reaches the primary route action with visible focus", async ({
|
||||
test("@a11y keyboard reaches a visible Public navigation focus indicator", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const action = page.getByRole("link", { name: "플랫폼 구성 보기" });
|
||||
await expect(action).toBeVisible();
|
||||
await page.keyboard.press("Tab");
|
||||
await gotoTechLog(page, "/");
|
||||
const action = page.getByRole("link", { name: "TechLog 홈" });
|
||||
await action.focus();
|
||||
await expect(action).toBeFocused();
|
||||
await expect(action).toHaveCSS("outline-style", "solid");
|
||||
});
|
||||
|
||||
test("@a11y reduced-motion policy disables long animation", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/");
|
||||
const duration = await page
|
||||
.locator("body")
|
||||
.evaluate((body) => getComputedStyle(body).animationDuration);
|
||||
expect(["0s", "0.00001s", "1e-05s"]).toContain(duration);
|
||||
});
|
||||
|
||||
test("@a11y opened design-system dialog has no blocking violations", async ({
|
||||
test("@a11y reduced-motion preference leaves no running document animations", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/examples/ui");
|
||||
await page.getByRole("button", { name: "모달 열기" }).click();
|
||||
const results = await new AxeBuilder({ page })
|
||||
.include(".ui-dialog")
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(
|
||||
results.violations.filter((violation) =>
|
||||
["critical", "serious"].includes(violation.impact ?? ""),
|
||||
),
|
||||
).toEqual([]);
|
||||
await gotoTechLog(page, "/");
|
||||
expect(await page.evaluate(() => document.getAnimations().length)).toBe(0);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import { gotoTechLog } from "../support/browser/tech-log-fixtures.ts";
|
||||
|
||||
test("boots the public app shell", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await gotoTechLog(page, "/");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
|
||||
"Tech Log",
|
||||
);
|
||||
@@ -9,36 +10,32 @@ test("boots the public app shell", async ({ page }) => {
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
});
|
||||
|
||||
test("navigates to a registry-backed example without a page reload", async ({
|
||||
test("navigates to a registry-backed Public route without a page reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await gotoTechLog(page, "/");
|
||||
await page
|
||||
.getByRole("navigation", { name: "주요 탐색" })
|
||||
.getByRole("link", { name: "화면 상태" })
|
||||
.getByRole("link", { name: "프로젝트" })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/examples\/states$/);
|
||||
await expect(page).toHaveURL(/\/projects$/);
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: "화면 상태" }),
|
||||
).toBeFocused();
|
||||
page.getByRole("heading", { level: 1, name: "프로젝트" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("provides an escape-dismissible mobile navigation", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/");
|
||||
const menu = page.getByRole("button", { name: "메뉴", exact: true });
|
||||
await gotoTechLog(page, "/");
|
||||
const menu = page.locator("details.mobile-nav > summary");
|
||||
|
||||
await menu.click();
|
||||
await expect(menu).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
await expect(page.locator(".ui-drawer")).toHaveJSProperty("open", true);
|
||||
expect(
|
||||
await page.locator(".ui-drawer").evaluate((drawer) => drawer.matches(":modal")),
|
||||
).toBe(true);
|
||||
await expect(page.locator("details.mobile-nav")).toHaveAttribute("open", "");
|
||||
await expect(page.getByRole("navigation", { name: "모바일 주요 탐색" })).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeHidden();
|
||||
await expect(page.locator("details.mobile-nav")).not.toHaveAttribute("open", "");
|
||||
await expect(page.getByRole("navigation", { name: "모바일 주요 탐색" })).toBeHidden();
|
||||
await expect(menu).toBeFocused();
|
||||
});
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("boots and navigates the compact production shell", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
const menu = page.getByRole("button", { name: "메뉴", exact: true });
|
||||
await expect(menu).toHaveCSS("min-width", "44px");
|
||||
await menu.click();
|
||||
const navigation = page.getByRole("navigation", { name: "주요 탐색" });
|
||||
await expect(navigation).toBeVisible();
|
||||
// The drawer is a non-modal dialog, so page content stays in the
|
||||
// accessibility tree while it is open. Scope to the navigation and match the
|
||||
// whole name, or a route call to action on the page behind it also matches.
|
||||
await navigation
|
||||
.getByRole("link", { name: "UI 구성요소", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/examples\/ui$/);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-build-id", "local-build");
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - window.innerWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("runs menu typeahead, tabs and duplicate toast interactions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/examples/ui");
|
||||
|
||||
const menuTrigger = page.getByRole("button", { name: "작업 메뉴" });
|
||||
await menuTrigger.focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.type("toast");
|
||||
const toastItem = page.getByRole("menuitem", { name: "Toast 표시" });
|
||||
await expect(toastItem).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByText("예제가 저장되었습니다.")).toBeVisible();
|
||||
await expect(menuTrigger).toBeFocused();
|
||||
|
||||
const tokenTab = page.getByRole("tab", { name: "토큰" });
|
||||
await tokenTab.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const primitiveTab = page.getByRole("tab", { name: "프리미티브" });
|
||||
await expect(primitiveTab).toBeFocused();
|
||||
await expect(primitiveTab).toHaveAttribute("aria-selected", "false");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(primitiveTab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(
|
||||
page.getByRole("tabpanel", { name: "프리미티브" }),
|
||||
).toContainText("native semantics");
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("switches the shell locale and keeps pseudo-locale copy within compact layout", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 320, height: 720 });
|
||||
await page.goto("/");
|
||||
|
||||
const locale = page.getByRole("combobox", { name: "언어" });
|
||||
await locale.selectOption("en-US");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "ltr");
|
||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||
|
||||
await page.getByRole("combobox", { name: "Language" }).selectOption("en-XA");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-XA");
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
|
||||
await expect(page.getByRole("button", { name: /Ménú/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("applies RTL to the shell and modal navigation without changing action semantics", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/");
|
||||
await page.getByRole("combobox", { name: "언어" }).selectOption("ar-EG");
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "ar-EG");
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
|
||||
const trigger = page.getByRole("button", { name: "Menu" });
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog", { name: "Menu" })).toBeVisible();
|
||||
await expect(page.getByRole("navigation", { name: "Primary navigation" }))
|
||||
.toHaveCount(1);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
@@ -1,36 +1,36 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import {
|
||||
gotoTechLog,
|
||||
horizontalOverflow,
|
||||
TECH_LOG_CANONICAL_ROUTES,
|
||||
} from "../support/browser/tech-log-fixtures.ts";
|
||||
|
||||
test("reflows the UI gallery at the 320px minimum without horizontal overflow", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 320, height: 720 });
|
||||
await page.goto("/examples/ui");
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: "UI 구성요소" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "메뉴", exact: true }),
|
||||
).toBeVisible();
|
||||
for (const route of TECH_LOG_CANONICAL_ROUTES) {
|
||||
for (const width of [360, 1440] as const) {
|
||||
test(`${route.routeId} has no horizontal overflow at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await gotoTechLog(page, route.path);
|
||||
const dimensions = await horizontalOverflow(page);
|
||||
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
|
||||
if (route.routeId !== "NOT_FOUND") {
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
} else {
|
||||
await expect(page.getByText("Not Found", { exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const viewport = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth);
|
||||
});
|
||||
|
||||
test("keeps desktop navigation and two-column examples at wide viewports", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/examples/ui");
|
||||
|
||||
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "메뉴", exact: true }),
|
||||
).toBeHidden();
|
||||
await expect(page.locator(".component-grid--two").first()).toHaveCSS(
|
||||
"grid-template-columns",
|
||||
/.+px .+px/,
|
||||
test("compact Public controls retain 44px targets", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await gotoTechLog(page, "/");
|
||||
await expect(page.locator("details.mobile-nav > summary")).toHaveCSS(
|
||||
"min-height",
|
||||
"44px",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "검색 열기" })).toHaveCSS(
|
||||
"min-height",
|
||||
"44px",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import { gotoTechLog } from "../support/browser/tech-log-fixtures.ts";
|
||||
|
||||
function blockingViolations(results: Awaited<ReturnType<AxeBuilder["analyze"]>>) {
|
||||
return results.violations.filter((violation) =>
|
||||
["critical", "serious"].includes(violation.impact ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
test("Public shell exposes one main landmark, one h1, labelled navigation, and current link", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoTechLog(page, "/projects");
|
||||
await expect(page.getByRole("main")).toHaveCount(1);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1);
|
||||
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "프로젝트", exact: true })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
});
|
||||
|
||||
test("Studio mobile navigation is keyboard reachable and exposes its expanded state", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await gotoTechLog(page, "/studio");
|
||||
const trigger = page.locator(".studio-menu-trigger");
|
||||
await trigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByRole("navigation", { name: "Studio 모바일 탐색" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("dirty-leave dialog is labelled, traps focus, and restores the invoking link", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoTechLog(
|
||||
page,
|
||||
"/studio/documents/11111111-1111-4111-8111-111111111111/edit",
|
||||
);
|
||||
await page.getByLabel("요약").fill("저장하지 않은 접근성 검증 변경");
|
||||
const trigger = page.getByRole("link", { name: "게시 기록", exact: true }).first();
|
||||
await trigger.click();
|
||||
const dialog = page.getByRole("dialog", { name: "저장하지 않은 변경" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole("button", { name: "이 페이지에 머무르기" })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test("open Public search and Studio leave dialogs have no blocking Axe findings", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoTechLog(page, "/");
|
||||
await page.getByRole("button", { name: "검색 열기" }).click();
|
||||
let results = await new AxeBuilder({ page })
|
||||
.include("dialog")
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(blockingViolations(results)).toEqual([]);
|
||||
|
||||
await gotoTechLog(
|
||||
page,
|
||||
"/studio/documents/11111111-1111-4111-8111-111111111111/edit",
|
||||
);
|
||||
await page.getByLabel("요약").fill("dialog axe state");
|
||||
await page.getByRole("link", { name: "게시 기록", exact: true }).first().click();
|
||||
results = await new AxeBuilder({ page })
|
||||
.include("dialog")
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(blockingViolations(results)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import {
|
||||
gotoTechLog,
|
||||
TECH_LOG_PUBLIC_FIXTURE_PATHS,
|
||||
} from "../support/browser/tech-log-fixtures.ts";
|
||||
|
||||
for (const path of TECH_LOG_PUBLIC_FIXTURE_PATHS) {
|
||||
test(`renders known Public fixture ${path}`, async ({ page }) => {
|
||||
await gotoTechLog(page, path);
|
||||
await expect(page.locator(".site-frame")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
await expect(page.getByText("404", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("search dialog traps focus, closes with Escape, and restores its trigger", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoTechLog(page, "/");
|
||||
const trigger = page.getByRole("button", { name: "TechLog 검색 열기" });
|
||||
await trigger.click();
|
||||
const dialog = page.getByRole("dialog", { name: "TechLog 검색" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole("searchbox", { name: "검색어" })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(dialog.getByRole("searchbox", { name: "검색어" })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test("search and explore navigation preserve canonical URL state", async ({ page }) => {
|
||||
await gotoTechLog(page, "/explore");
|
||||
await page.getByLabel("유형").selectOption("CASE");
|
||||
await page.getByRole("button", { name: "적용" }).click();
|
||||
await expect(page).toHaveURL(/\/explore\?type=CASE$/);
|
||||
|
||||
await page.getByRole("button", { name: "TechLog 검색 열기" }).click();
|
||||
await page.getByRole("searchbox", { name: "검색어" }).fill("JPA");
|
||||
await page.getByRole("link", { name: /전체 검색 결과 보기/ }).click();
|
||||
await expect(page).toHaveURL(/\/search\?q=JPA$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "검색" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
import {
|
||||
gotoTechLog,
|
||||
horizontalOverflow,
|
||||
TECH_LOG_BREAKPOINT_WIDTHS,
|
||||
} from "../support/browser/tech-log-fixtures.ts";
|
||||
|
||||
for (const width of TECH_LOG_BREAKPOINT_WIDTHS) {
|
||||
test(`Public shell has no overflow and preserves the ${width}px breakpoint contract`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await gotoTechLog(page, "/");
|
||||
const dimensions = await horizontalOverflow(page);
|
||||
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
|
||||
|
||||
if (width <= 1050) {
|
||||
await expect(page.locator("details.mobile-nav > summary")).toBeVisible();
|
||||
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeHidden();
|
||||
} else {
|
||||
await expect(page.locator("details.mobile-nav > summary")).toBeHidden();
|
||||
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [1050, 1024, 980, 900, 820, 768, 767, 420, 390, 375] as const) {
|
||||
test(`Studio editor has no overflow and preserves the ${width}px editor transition`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await gotoTechLog(
|
||||
page,
|
||||
"/studio/documents/11111111-1111-4111-8111-111111111111/edit",
|
||||
);
|
||||
const dimensions = await horizontalOverflow(page);
|
||||
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "저장" })).toBeVisible();
|
||||
});
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("persists an explicit color scheme through the storage contract", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const selector = page.getByRole("combobox", { name: "색상 테마" });
|
||||
|
||||
await selector.selectOption("dark");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await expect(page.locator("html")).toHaveCSS("color-scheme", "dark");
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(
|
||||
results.violations.filter((violation) =>
|
||||
["critical", "serious"].includes(violation.impact ?? ""),
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
await page.reload();
|
||||
await expect(selector).toHaveValue("dark");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
});
|
||||
|
||||
test("tracks operating-system changes while system preference is selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.goto("/");
|
||||
const selector = page.getByRole("combobox", { name: "색상 테마" });
|
||||
await selector.selectOption("system");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||
});
|
||||
@@ -372,20 +372,18 @@ describe("TechLog explore discovery", () => {
|
||||
).toHaveAttribute("href", "/explore");
|
||||
});
|
||||
|
||||
it("renders an unknown kind through the registered Public not-found runtime", () => {
|
||||
it("renders an unknown kind as the source production plain 404", async () => {
|
||||
const { router, container } = renderDiscoveryRoute(
|
||||
"TECH_LOG_EXPLORE_KIND",
|
||||
"/explore/unknown",
|
||||
);
|
||||
|
||||
expect(router.state.location.pathname).toBe("/explore/unknown");
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
||||
).toBeVisible();
|
||||
expect(await screen.findByText("Not Found", { exact: true })).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".site-frame")).not.toBeNull();
|
||||
expect(container.querySelector(".site-frame")).toBeNull();
|
||||
});
|
||||
|
||||
it("recovers a rejecting registered not-found runtime under its own chunk contract", async () => {
|
||||
|
||||
@@ -337,12 +337,12 @@ describe("TechLog topics and Public not-found routing", () => {
|
||||
["TECH_LOG_REFERENCE" as const, "/references/not-registered"],
|
||||
["TECH_LOG_QUESTION" as const, "/questions/not-registered"],
|
||||
["TECH_LOG_TOPIC" as const, "/topics/not-registered"],
|
||||
])("uses the registered in-shell Public not-found for %s", (routeId, path) => {
|
||||
])("uses the source production plain 404 for %s", async (routeId, path) => {
|
||||
const { container, router } = renderDocumentRoute(routeId, path);
|
||||
|
||||
expect(router.state.location.pathname).toBe(path);
|
||||
expect(screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." })).toBeVisible();
|
||||
expect(await screen.findByText("Not Found", { exact: true })).toBeVisible();
|
||||
expect(screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." })).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".site-frame")).not.toBeNull();
|
||||
expect(container.querySelector(".site-frame")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -360,20 +360,25 @@ describe("TechLog Public not-found runtime", () => {
|
||||
["TECH_LOG_PROJECT_RECORDS" as const, "/projects/missing-project/records"],
|
||||
["TECH_LOG_RELEASE" as const, "/releases/9.9.9"],
|
||||
["TECH_LOG_CASE" as const, "/cases/not-registered"],
|
||||
["NOT_FOUND" as const, "/definitely-not-a-product-route"],
|
||||
])("renders exact in-shell Public fallback copy for %s", (routeId, path) => {
|
||||
])("renders the source-compatible plain fallback for %s", async (routeId, path) => {
|
||||
const { container, router } = renderPublicRoute(routeId, path);
|
||||
|
||||
expect(router.state.location.pathname).toBe(path);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "404" })).toHaveClass(
|
||||
"next-error-h1",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "This page could not be found." }),
|
||||
).toBeVisible();
|
||||
expect(await screen.findByText("Not Found", { exact: true })).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".site-frame")).not.toBeNull();
|
||||
expect(container.querySelector(".site-frame")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the source-compatible plain fallback for the Public catch-all", () => {
|
||||
const { container, router } = renderPublicRoute(
|
||||
"NOT_FOUND",
|
||||
"/definitely-not-a-product-route",
|
||||
);
|
||||
|
||||
expect(router.state.location.pathname).toBe("/definitely-not-a-product-route");
|
||||
expect(screen.getByText("Not Found", { exact: true })).toBeVisible();
|
||||
expect(container.querySelector(".site-frame")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
export const TECH_LOG_FIXED_TIME = "2026-08-14T01:00:00.000Z";
|
||||
|
||||
export const TECH_LOG_CANONICAL_ROUTES = [
|
||||
{ routeId: "TECH_LOG_HOME", path: "/", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_EXPLORE", path: "/explore", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_EXPLORE_KIND", path: "/explore/cases", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_CASE", path: "/cases/collection-fetch-join-pagination", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_REFERENCE", path: "/references/state-and-nonce-boundary", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_QUESTION", path: "/questions/validate-edge-token-again", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_TOPIC", path: "/topics/jpa", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_PROJECTS", path: "/projects", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_PROJECT", path: "/projects/backend-skeleton", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_PROJECT_RECORDS", path: "/projects/backend-skeleton/records", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_PROJECT_DECISIONS", path: "/projects/backend-skeleton/decisions", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_PROJECT_ACTIVITY", path: "/projects/backend-skeleton/activity", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_RELEASES", path: "/releases", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_RELEASE", path: "/releases/0.1.0", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_PROFILE", path: "/profile", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_SEARCH", path: "/search?q=JPA", layout: "PUBLIC" },
|
||||
{ routeId: "TECH_LOG_STUDIO_HOME", path: "/studio", layout: "STUDIO" },
|
||||
{ routeId: "TECH_LOG_STUDIO_DOCUMENTS", path: "/studio/documents", layout: "STUDIO" },
|
||||
{ routeId: "TECH_LOG_STUDIO_DOCUMENT_NEW", path: "/studio/documents/new", layout: "STUDIO" },
|
||||
{ routeId: "TECH_LOG_STUDIO_DOCUMENT_EDIT", path: "/studio/documents/11111111-1111-4111-8111-111111111111/edit", layout: "STUDIO" },
|
||||
{ routeId: "TECH_LOG_STUDIO_DOCUMENT_VALIDATION", path: "/studio/documents/11111111-1111-4111-8111-111111111113/validation", layout: "STUDIO" },
|
||||
{ routeId: "TECH_LOG_STUDIO_DOCUMENT_PREVIEW", path: "/studio/documents/11111111-1111-4111-8111-111111111111/preview", layout: "STUDIO" },
|
||||
{ routeId: "TECH_LOG_STUDIO_DOCUMENT_PUBLISH", path: "/studio/documents/11111111-1111-4111-8111-111111111113/publish", layout: "STUDIO" },
|
||||
{ routeId: "TECH_LOG_STUDIO_PUBLICATIONS", path: "/studio/publications", layout: "STUDIO" },
|
||||
{ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/66666666-6666-4666-8666-666666666661/preview", layout: "STUDIO" },
|
||||
{ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/unknown-screen", layout: "STUDIO" },
|
||||
{ routeId: "NOT_FOUND", path: "/unknown-public-screen", layout: "PUBLIC" },
|
||||
] as const;
|
||||
|
||||
export const TECH_LOG_PUBLIC_FIXTURE_PATHS = [
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/cases/redis-adapter-ttl-boundary",
|
||||
"/references/state-and-nonce-boundary",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
"/questions/validate-edge-token-again",
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
"/topics/jpa",
|
||||
"/topics/redis",
|
||||
"/topics/authentication",
|
||||
"/projects/backend-skeleton",
|
||||
"/projects/backend-skeleton/records",
|
||||
"/projects/backend-skeleton/decisions",
|
||||
"/projects/backend-skeleton/activity",
|
||||
"/projects/auth-lab",
|
||||
"/projects/auth-lab/records",
|
||||
"/projects/auth-lab/decisions",
|
||||
"/projects/auth-lab/activity",
|
||||
"/releases/0.1.0",
|
||||
] as const;
|
||||
|
||||
export const TECH_LOG_UNKNOWN_PUBLIC_PATHS = [
|
||||
["explore-kind", "/explore/unknown-kind"],
|
||||
["case", "/cases/not-registered"],
|
||||
["reference", "/references/not-registered"],
|
||||
["question", "/questions/not-registered"],
|
||||
["topic", "/topics/not-registered"],
|
||||
["project", "/projects/missing-project"],
|
||||
["project-records", "/projects/missing-project/records"],
|
||||
["project-decisions", "/projects/missing-project/decisions"],
|
||||
["project-activity", "/projects/missing-project/activity"],
|
||||
["release", "/releases/9.9.9"],
|
||||
] as const;
|
||||
|
||||
export const TECH_LOG_STUDIO_STATE_PATHS = [
|
||||
["dashboard", "/studio"],
|
||||
["document-list", "/studio/documents"],
|
||||
["new-document", "/studio/documents/new"],
|
||||
["case-editor", "/studio/documents/11111111-1111-4111-8111-111111111111/edit"],
|
||||
["reference-editor", "/studio/documents/11111111-1111-4111-8111-111111111113/edit"],
|
||||
["question-editor", "/studio/documents/11111111-1111-4111-8111-111111111114/edit"],
|
||||
["conflict-editor", "/studio/documents/11111111-1111-4111-8111-111111111115/edit"],
|
||||
["valid-validation", "/studio/documents/11111111-1111-4111-8111-111111111113/validation"],
|
||||
["invalid-validation", "/studio/documents/11111111-1111-4111-8111-111111111114/validation"],
|
||||
["current-preview", "/studio/documents/11111111-1111-4111-8111-111111111111/preview"],
|
||||
["missing-preview", "/studio/documents/11111111-1111-4111-8111-111111111113/preview"],
|
||||
["expired-preview", "/studio/documents/11111111-1111-4111-8111-111111111117/preview"],
|
||||
["publish-ready", "/studio/documents/11111111-1111-4111-8111-111111111113/publish"],
|
||||
["publish-blocked", "/studio/documents/11111111-1111-4111-8111-111111111114/publish"],
|
||||
["publications", "/studio/publications"],
|
||||
["publication-snapshot", "/studio/publications/66666666-6666-4666-8666-666666666661/preview"],
|
||||
["missing-document", "/studio/documents/99999999-9999-4999-8999-999999999999/edit"],
|
||||
["missing-publication", "/studio/publications/99999999-9999-4999-8999-999999999999/preview"],
|
||||
["unknown-studio-route", "/studio/unknown-screen"],
|
||||
] as const;
|
||||
|
||||
export const TECH_LOG_BREAKPOINT_WIDTHS = [
|
||||
1180,
|
||||
1179,
|
||||
1050,
|
||||
1024,
|
||||
980,
|
||||
900,
|
||||
820,
|
||||
768,
|
||||
767,
|
||||
420,
|
||||
390,
|
||||
375,
|
||||
] as const;
|
||||
|
||||
export async function prepareTechLogPage(page: Page) {
|
||||
await page.clock.setFixedTime(new Date(TECH_LOG_FIXED_TIME));
|
||||
await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" });
|
||||
}
|
||||
|
||||
export async function settleTechLogPage(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
});
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
*, *::before, *::after {
|
||||
animation-delay: 0s !important;
|
||||
animation-duration: 0s !important;
|
||||
caret-color: transparent !important;
|
||||
transition-delay: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function gotoTechLog(page: Page, path: string) {
|
||||
await prepareTechLogPage(page);
|
||||
await page.goto(path);
|
||||
await page.locator("body").waitFor({ state: "visible" });
|
||||
await settleTechLogPage(page);
|
||||
}
|
||||
|
||||
export async function horizontalOverflow(page: Page) {
|
||||
return page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// @ts-nocheck -- standalone evidence runner executed directly with tsx.
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
import {
|
||||
TECH_LOG_BREAKPOINT_WIDTHS,
|
||||
TECH_LOG_CANONICAL_ROUTES,
|
||||
TECH_LOG_FIXED_TIME,
|
||||
TECH_LOG_STUDIO_STATE_PATHS,
|
||||
TECH_LOG_UNKNOWN_PUBLIC_PATHS,
|
||||
} from "./tech-log-fixtures.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { PNG } = require(
|
||||
resolve("node_modules/.pnpm/playwright-core@1.62.0/node_modules/playwright-core/lib/utilsBundle.js"),
|
||||
);
|
||||
|
||||
const sourceBaseUrl = process.env.TECH_LOG_SOURCE_URL ?? "http://127.0.0.1:4175";
|
||||
const targetBaseUrl = process.env.TECH_LOG_TARGET_URL ?? "http://127.0.0.1:4174";
|
||||
const outputPath = process.env.TECH_LOG_PARITY_OUTPUT ?? "artifacts/quality/tech-log-source-parity.json";
|
||||
const fixedDate = new Date(TECH_LOG_FIXED_TIME);
|
||||
const noMotionCss = `
|
||||
*, *::before, *::after {
|
||||
animation-delay: 0s !important;
|
||||
animation-duration: 0s !important;
|
||||
caret-color: transparent !important;
|
||||
transition-delay: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const allCases = [
|
||||
...TECH_LOG_CANONICAL_ROUTES.flatMap(({ routeId, path }) =>
|
||||
[360, 1440].map((width) => ({ name: `${routeId}-${width}`, path, width }))),
|
||||
...TECH_LOG_BREAKPOINT_WIDTHS.map((width) => ({
|
||||
name: `TECH_LOG_HOME-BREAKPOINT-${width}`,
|
||||
path: "/",
|
||||
width,
|
||||
})),
|
||||
...TECH_LOG_STUDIO_STATE_PATHS.map(([state, path]) => ({
|
||||
name: `TECH_LOG_STUDIO_STATE-${state}`,
|
||||
path,
|
||||
width: 1440,
|
||||
})),
|
||||
...TECH_LOG_UNKNOWN_PUBLIC_PATHS.flatMap(([route, path]) =>
|
||||
[360, 1440].map((width) => ({
|
||||
name: `TECH_LOG_UNKNOWN_${route.toUpperCase().replaceAll("-", "_")}-${width}`,
|
||||
path,
|
||||
width,
|
||||
}))),
|
||||
{ name: "TECH_LOG_SEARCH_DIALOG", path: "/", width: 390, action: "search-dialog" },
|
||||
{ name: "TECH_LOG_STUDIO_MOBILE_MENU", path: "/studio", width: 390, action: "studio-menu" },
|
||||
{
|
||||
name: "TECH_LOG_STUDIO_IMMEDIATE_PREVIEW",
|
||||
path: "/studio/documents/11111111-1111-4111-8111-111111111111/edit",
|
||||
width: 1440,
|
||||
action: "immediate-preview",
|
||||
},
|
||||
{
|
||||
name: "TECH_LOG_STUDIO_DIRTY_DIALOG",
|
||||
path: "/studio/documents/11111111-1111-4111-8111-111111111111/edit",
|
||||
width: 1440,
|
||||
action: "dirty-dialog",
|
||||
},
|
||||
{
|
||||
name: "TECH_LOG_STUDIO_CURRENT_PREVIEW_CREATED",
|
||||
path: "/studio/documents/11111111-1111-4111-8111-111111111113/preview",
|
||||
width: 1440,
|
||||
action: "create-preview",
|
||||
},
|
||||
{
|
||||
name: "TECH_LOG_STUDIO_UNPUBLISH_DIALOG",
|
||||
path: "/studio/publications",
|
||||
width: 1440,
|
||||
action: "unpublish-dialog",
|
||||
},
|
||||
{
|
||||
name: "TECH_LOG_STUDIO_WARNING_ACKNOWLEDGED",
|
||||
path: "/studio/documents/11111111-1111-4111-8111-111111111116/validation",
|
||||
width: 1440,
|
||||
action: "warning-acknowledged",
|
||||
},
|
||||
];
|
||||
const caseFilter = process.env.TECH_LOG_PARITY_CASE;
|
||||
const cases = caseFilter
|
||||
? allCases.filter(({ name }) => name.includes(caseFilter))
|
||||
: allCases;
|
||||
const diagnosticDirectory = process.env.TECH_LOG_PARITY_DIAGNOSTICS ?? "/tmp/techlog-parity-diagnostics";
|
||||
|
||||
async function interact(page, action) {
|
||||
if (action === "search-dialog") {
|
||||
await page.getByRole("button", { name: "검색 열기" }).click();
|
||||
} else if (action === "studio-menu") {
|
||||
await page.getByRole("button", { name: "Studio 메뉴 열기" }).click();
|
||||
} else if (action === "immediate-preview") {
|
||||
await page.getByRole("tab", { name: "즉시 미리보기" }).click();
|
||||
} else if (action === "dirty-dialog") {
|
||||
await page.getByLabel("요약").fill("저장하지 않은 시각 검증 변경");
|
||||
await page.getByRole("link", { name: "게시 기록", exact: true }).first().click();
|
||||
} else if (action === "create-preview") {
|
||||
await page.getByRole("button", { name: "Public Preview 만들기" }).click();
|
||||
await page.getByText("현재 저장 버전의 Public Preview입니다.").waitFor();
|
||||
} else if (action === "unpublish-dialog") {
|
||||
await page.getByRole("button", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가 게시 취소/ }).click();
|
||||
} else if (action === "warning-acknowledged") {
|
||||
await page.getByRole("button", { name: "검증하기" }).click();
|
||||
await page.getByText("경고를 확인하고 Preview를 만들 수 있습니다").waitFor();
|
||||
await page.getByRole("link", { name: "Public Preview 만들기" }).click();
|
||||
await page.getByRole("button", { name: "Public Preview 만들기" }).click();
|
||||
await page.getByText("현재 저장 버전의 Public Preview입니다.").waitFor();
|
||||
await page.getByRole("link", { name: "게시 준비로 이동" }).click();
|
||||
await page.getByRole("checkbox").check();
|
||||
await page.getByRole("button", { name: /게시$/ }).waitFor();
|
||||
}
|
||||
}
|
||||
|
||||
async function settle(page, baseUrl, parityCase) {
|
||||
await page.setViewportSize({ width: parityCase.width, height: 1000 });
|
||||
await page.clock.setFixedTime(fixedDate);
|
||||
await page.goto(new URL(parityCase.path, baseUrl).href, { waitUntil: "networkidle" });
|
||||
await page.locator("body").waitFor({ state: "visible" });
|
||||
await page.evaluate(async () => document.fonts.ready);
|
||||
await page.addStyleTag({ content: noMotionCss });
|
||||
await interact(page, parityCase.action);
|
||||
await page.evaluate(async () => document.fonts.ready);
|
||||
}
|
||||
|
||||
async function projection(page) {
|
||||
return page.evaluate(() => {
|
||||
const normalizeReference = (value) => value
|
||||
.split(" ")
|
||||
.map((token) => token.replace(/^_[rR].*?_(?=-|$)/, "<generated-id>"))
|
||||
.join(" ");
|
||||
const attributes = (element) => Object.fromEntries(
|
||||
[...element.attributes]
|
||||
.filter(({ name }) => name === "role" || name.startsWith("aria-"))
|
||||
.map(({ name, value }) => [name, normalizeReference(value)])
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
return [...document.querySelectorAll("header, main, footer, dialog")].map((element) => ({
|
||||
tag: element.tagName.toLowerCase(),
|
||||
className: [...element.classList].map((name) => name.replace(/^_([A-Za-z0-9]+)_[A-Za-z0-9]+_(\d+)$/, "_$1_<generated-module>_$2")).sort().join(" "),
|
||||
text: element.textContent?.replace(/\s+/g, " ").trim() ?? "",
|
||||
aria: attributes(element),
|
||||
descendants: [...element.querySelectorAll("[role], [aria-label], [aria-labelledby], [aria-describedby], [aria-current], [aria-expanded], [aria-controls]")]
|
||||
.map((child) => ({
|
||||
tag: child.tagName.toLowerCase(),
|
||||
className: [...child.classList].map((name) => name.replace(/^_([A-Za-z0-9]+)_[A-Za-z0-9]+_(\d+)$/, "_$1_<generated-module>_$2")).sort().join(" "),
|
||||
text: child.textContent?.replace(/\s+/g, " ").trim() ?? "",
|
||||
aria: attributes(child),
|
||||
})),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function layoutProjection(page) {
|
||||
return page.evaluate(() => [...document.querySelectorAll("header, main, main *, footer, dialog, .studio-app *")].map((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
tag: element.tagName.toLowerCase(),
|
||||
className: [...element.classList].sort().join(" "),
|
||||
top: box.top,
|
||||
left: box.left,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
margin: style.margin,
|
||||
padding: style.padding,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
function pixelDifference(sourceBuffer, targetBuffer) {
|
||||
const source = PNG.sync.read(sourceBuffer);
|
||||
const target = PNG.sync.read(targetBuffer);
|
||||
if (source.width !== target.width || source.height !== target.height) {
|
||||
return { pixels: null, sourceSize: [source.width, source.height], targetSize: [target.width, target.height] };
|
||||
}
|
||||
let pixels = 0;
|
||||
for (let offset = 0; offset < source.data.length; offset += 4) {
|
||||
if (
|
||||
source.data[offset] !== target.data[offset] ||
|
||||
source.data[offset + 1] !== target.data[offset + 1] ||
|
||||
source.data[offset + 2] !== target.data[offset + 2] ||
|
||||
source.data[offset + 3] !== target.data[offset + 3]
|
||||
) pixels += 1;
|
||||
}
|
||||
return { pixels, sourceSize: [source.width, source.height], targetSize: [target.width, target.height] };
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const contextOptions = {
|
||||
colorScheme: "light",
|
||||
deviceScaleFactor: 1,
|
||||
locale: "ko-KR",
|
||||
reducedMotion: "reduce",
|
||||
serviceWorkers: "block",
|
||||
timezoneId: "Asia/Seoul",
|
||||
};
|
||||
const sourceContext = await browser.newContext(contextOptions);
|
||||
const targetContext = await browser.newContext(contextOptions);
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
for (const parityCase of cases) {
|
||||
const sourcePage = await sourceContext.newPage();
|
||||
const targetPage = await targetContext.newPage();
|
||||
const sourceErrors = [];
|
||||
const targetErrors = [];
|
||||
for (const [page, errors] of [[sourcePage, sourceErrors], [targetPage, targetErrors]]) {
|
||||
page.on("console", (message) => { if (message.type() === "error") errors.push(`console: ${message.text()}`); });
|
||||
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
|
||||
page.on("requestfailed", (request) => errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText ?? ""}`));
|
||||
}
|
||||
await Promise.all([
|
||||
settle(sourcePage, sourceBaseUrl, parityCase),
|
||||
settle(targetPage, targetBaseUrl, parityCase),
|
||||
]);
|
||||
const [sourceShot, targetShot, sourceDom, targetDom, sourceLayout, targetLayout, sourceFonts, targetFonts] = await Promise.all([
|
||||
sourcePage.screenshot({ fullPage: true }),
|
||||
targetPage.screenshot({ fullPage: true }),
|
||||
projection(sourcePage),
|
||||
projection(targetPage),
|
||||
layoutProjection(sourcePage),
|
||||
layoutProjection(targetPage),
|
||||
sourcePage.evaluate(() => [...document.fonts].map(({ family, status }) => ({ family, status })).filter(({ family }) => /Pretendard|IBM Plex Mono/.test(family))),
|
||||
targetPage.evaluate(() => [...document.fonts].map(({ family, status }) => ({ family, status })).filter(({ family }) => /Pretendard|IBM Plex Mono/.test(family))),
|
||||
]);
|
||||
const pixel = pixelDifference(sourceShot, targetShot);
|
||||
const domEqual = JSON.stringify(sourceDom) === JSON.stringify(targetDom);
|
||||
const fontsEqual = JSON.stringify(sourceFonts) === JSON.stringify(targetFonts);
|
||||
const isPlainNotFound =
|
||||
parityCase.name.includes("NOT_FOUND") || parityCase.name.includes("UNKNOWN");
|
||||
const expectsSourceNotFoundResponse =
|
||||
isPlainNotFound || parityCase.path.includes("unknown");
|
||||
const expectedNotFoundConsole = expectsSourceNotFoundResponse
|
||||
? (message) => message === "console: Failed to load resource: the server responded with a status of 404 (Not Found)"
|
||||
: () => false;
|
||||
const unexplainedSourceErrors = sourceErrors.filter((message) => !expectedNotFoundConsole(message));
|
||||
const unexplainedTargetErrors = targetErrors.filter((message) => !expectedNotFoundConsole(message));
|
||||
const fontContractApplies = !isPlainNotFound;
|
||||
const passed = pixel.pixels === 0 && domEqual && (!fontContractApplies || fontsEqual) && unexplainedSourceErrors.length === 0 && unexplainedTargetErrors.length === 0;
|
||||
if (!passed) {
|
||||
await mkdir(diagnosticDirectory, { recursive: true });
|
||||
const stem = parityCase.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
|
||||
await Promise.all([
|
||||
writeFile(resolve(diagnosticDirectory, `${stem}-source.png`), sourceShot),
|
||||
writeFile(resolve(diagnosticDirectory, `${stem}-target.png`), targetShot),
|
||||
writeFile(resolve(diagnosticDirectory, `${stem}-dom.json`), `${JSON.stringify({ sourceDom, targetDom, sourceLayout, targetLayout }, null, 2)}\n`),
|
||||
]);
|
||||
}
|
||||
results.push({ name: parityCase.name, path: parityCase.path, width: parityCase.width, action: parityCase.action ?? null, pixel, domEqual, fontsEqual, fontContractApplies, sourceFonts, targetFonts, sourceErrors, targetErrors, unexplainedSourceErrors, unexplainedTargetErrors, passed });
|
||||
console.log(`${passed ? "PASS" : "FAIL"} ${parityCase.name} pixels=${pixel.pixels ?? "SIZE"} dom=${domEqual} fonts=${fontContractApplies ? fontsEqual : "source-plain-text"} errors=${unexplainedSourceErrors.length}/${unexplainedTargetErrors.length}`);
|
||||
await Promise.all([sourcePage.close(), targetPage.close()]);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all([sourceContext.close(), targetContext.close()]);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const evidence = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
sourceBaseUrl,
|
||||
targetBaseUrl,
|
||||
conditions: { ...contextOptions, fixedTime: TECH_LOG_FIXED_TIME, viewportHeight: 1000, screenshots: "fullPage", masks: 0 },
|
||||
total: results.length,
|
||||
passed: results.filter((result) => result.passed).length,
|
||||
failed: results.filter((result) => !result.passed).length,
|
||||
totalDifferentPixels: results.reduce((sum, result) => sum + (result.pixel.pixels ?? 0), 0),
|
||||
results,
|
||||
};
|
||||
await mkdir(resolve(outputPath, ".."), { recursive: true });
|
||||
await writeFile(outputPath, `${JSON.stringify(evidence, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ outputPath, total: evidence.total, passed: evidence.passed, failed: evidence.failed, totalDifferentPixels: evidence.totalDifferentPixels }));
|
||||
if (evidence.failed > 0) process.exitCode = 1;
|
||||
@@ -190,6 +190,7 @@ jobs:
|
||||
scripts/lib/secret-scan.ts \\
|
||||
scripts/lib/supply-chain.ts \\
|
||||
scripts/lib/validated-json-artifact.ts \\
|
||||
scripts/lib/vite-route-chunks.ts \\
|
||||
src/contracts/release-artifacts.ts \\
|
||||
src/features/installed-contract-contributions.ts \\
|
||||
src/features/installed-feature-contracts.ts \\
|
||||
|
||||
@@ -121,6 +121,29 @@ describe("bounded body reader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("abandons and cancels a bounded read when its lifetime aborts", async () => {
|
||||
const controller = new AbortController();
|
||||
const reader = {
|
||||
read: vi.fn(() => new Promise<never>(() => {})),
|
||||
cancel: vi.fn().mockRejectedValue(new Error("abandoned cancel failed")),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = {
|
||||
headers: new Headers(),
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Response;
|
||||
|
||||
const outcome = readBoundedBytes(response, 3, controller.signal);
|
||||
controller.abort();
|
||||
|
||||
await expect(outcome).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "RESPONSE_STREAM_FAILURE",
|
||||
});
|
||||
expect(reader.cancel).toHaveBeenCalledOnce();
|
||||
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("detects a forbidden body from declared length without reading it", async () => {
|
||||
const cancel = vi.fn();
|
||||
const response = {
|
||||
@@ -200,6 +223,29 @@ describe("bounded body reader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("abandons and cancels a forbidden-body probe when its lifetime aborts", async () => {
|
||||
const controller = new AbortController();
|
||||
const reader = {
|
||||
read: vi.fn(() => new Promise<never>(() => {})),
|
||||
cancel: vi.fn().mockRejectedValue(new Error("abandoned cancel failed")),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = {
|
||||
headers: new Headers(),
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Response;
|
||||
|
||||
const outcome = probeForbiddenBody(response, controller.signal);
|
||||
controller.abort();
|
||||
|
||||
await expect(outcome).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "RESPONSE_STREAM_FAILURE",
|
||||
});
|
||||
expect(reader.cancel).toHaveBeenCalledOnce();
|
||||
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("decodes valid JSON and distinguishes UTF-8 from JSON failures", () => {
|
||||
expect(decodeJsonBytes(new TextEncoder().encode('{"ok":true}'))).toEqual({
|
||||
ok: true,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { containsRawPaletteValue } from "../../scripts/lib/design-system-source.ts";
|
||||
|
||||
describe("design-system source palette detection", () => {
|
||||
it("rejects palette literals without treating URL fragments as colors", () => {
|
||||
expect(containsRawPaletteValue('style={{ color: "#ff0000" }}')).toBe(true);
|
||||
expect(containsRawPaletteValue('const css = "color: rgb(255 0 0)"')).toBe(true);
|
||||
expect(
|
||||
containsRawPaletteValue(
|
||||
'path: "/projects/backend-skeleton/decisions#feed-pagination-boundary"',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -335,9 +335,9 @@ describe("registry governance manifest", () => {
|
||||
});
|
||||
expect(versions?.rows).toBeDefined();
|
||||
expect(approval).toMatchObject({
|
||||
owner: "frontend-platform",
|
||||
owner: "tech-log-frontend",
|
||||
reason:
|
||||
"Baseline canonical invalidation graph and topic-version contracts after FE-REG-QUERY retirement",
|
||||
"Install approved TechLog Public and Studio route contract",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1770,6 +1770,7 @@ async function createArchivedAssessmentFixture(): Promise<{
|
||||
"scripts/lib/secret-scan.ts",
|
||||
"scripts/lib/supply-chain.ts",
|
||||
"scripts/lib/validated-json-artifact.ts",
|
||||
"scripts/lib/vite-route-chunks.ts",
|
||||
"src/contracts/release-artifacts.ts",
|
||||
"src/features/installed-contract-contributions.ts",
|
||||
"src/features/installed-feature-contracts.ts",
|
||||
@@ -1799,6 +1800,7 @@ async function createArchivedAssessmentFixture(): Promise<{
|
||||
"scripts/lib/secret-scan.ts",
|
||||
"scripts/lib/supply-chain.ts",
|
||||
"scripts/lib/validated-json-artifact.ts",
|
||||
"scripts/lib/vite-route-chunks.ts",
|
||||
"src/contracts/release-artifacts.ts",
|
||||
"src/features/installed-contract-contributions.ts",
|
||||
"src/features/installed-feature-contracts.ts",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { findViteDynamicRouteChunk } from "../../scripts/lib/vite-route-chunks.ts";
|
||||
|
||||
describe("Vite route chunk identity", () => {
|
||||
it("recognizes a governed manual chunk referenced by an entry dynamic import", () => {
|
||||
const manifest = {
|
||||
"_route-tech-log-home-abc.js": {
|
||||
file: "assets/route-tech-log-home-abc.js",
|
||||
name: "route-tech-log-home",
|
||||
},
|
||||
"index.html": {
|
||||
file: "assets/index.js",
|
||||
name: "index",
|
||||
isEntry: true,
|
||||
dynamicImports: ["_route-tech-log-home-abc.js"],
|
||||
},
|
||||
};
|
||||
|
||||
expect(findViteDynamicRouteChunk(manifest, "route-tech-log-home")).toEqual({
|
||||
file: "assets/route-tech-log-home-abc.js",
|
||||
name: "route-tech-log-home",
|
||||
});
|
||||
});
|
||||
|
||||
it("retains Vite native dynamic-entry support", () => {
|
||||
const manifest = {
|
||||
"src/page.tsx": {
|
||||
file: "assets/page.js",
|
||||
name: "route-page",
|
||||
isDynamicEntry: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(findViteDynamicRouteChunk(manifest, "route-page")?.file).toBe(
|
||||
"assets/page.js",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a named chunk that is not dynamically reachable", () => {
|
||||
const manifest = {
|
||||
"_route-tech-log-home-abc.js": {
|
||||
file: "assets/route-tech-log-home-abc.js",
|
||||
name: "route-tech-log-home",
|
||||
},
|
||||
"index.html": {
|
||||
file: "assets/index.js",
|
||||
name: "index",
|
||||
isEntry: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(findViteDynamicRouteChunk(manifest, "route-tech-log-home")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 687 KiB |
|
After Width: | Height: | Size: 556 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 165 KiB |