fix: complete TechLog migration evidence
@@ -343,12 +343,15 @@ describe("generic application router", () => {
|
||||
expect(window.location.pathname).toBe("/projects");
|
||||
});
|
||||
|
||||
it("renders the source-compatible plain TechLog not-found response", async () => {
|
||||
it("keeps the unreachable client-side Public fallback inside its accessible shell", async () => {
|
||||
window.history.pushState({}, "", "/missing");
|
||||
renderRouter();
|
||||
|
||||
expect(await screen.findByText("Not Found", { exact: true })).toBeVisible();
|
||||
expect(screen.queryByRole("navigation", { name: "주요 탐색" })).not.toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
expect(screen.queryByText("Not Found", { exact: true })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Studio routes inside the persistent Studio layout", async () => {
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
TECH_LOG_CANONICAL_ROUTES,
|
||||
} from "../support/browser/tech-log-fixtures.ts";
|
||||
|
||||
for (const route of TECH_LOG_CANONICAL_ROUTES) {
|
||||
for (const route of TECH_LOG_CANONICAL_ROUTES.filter(
|
||||
({ routeId }) => routeId !== "NOT_FOUND",
|
||||
)) {
|
||||
test(`@a11y ${route.routeId} has no critical or serious Axe violations`, async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -22,6 +24,15 @@ for (const route of TECH_LOG_CANONICAL_ROUTES) {
|
||||
});
|
||||
}
|
||||
|
||||
test("@a11y NOT_FOUND preserves the source raw non-HTML response", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get("/definitely-not-a-product-route");
|
||||
expect(response.status()).toBe(404);
|
||||
expect(response.headers()["content-type"]).toBe("text/plain;charset=UTF-8");
|
||||
expect(await response.text()).toBe("Not Found");
|
||||
});
|
||||
|
||||
test("@a11y keyboard reaches a visible Public navigation focus indicator", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
import {
|
||||
TECH_LOG_CANONICAL_ROUTES,
|
||||
TECH_LOG_PUBLIC_FIXTURE_PATHS,
|
||||
} from "../support/browser/tech-log-fixtures.ts";
|
||||
|
||||
const knownSpaPaths = [
|
||||
...new Set([
|
||||
...TECH_LOG_CANONICAL_ROUTES
|
||||
.filter(({ routeId }) => !["NOT_FOUND", "TECH_LOG_STUDIO_NOT_FOUND"].includes(routeId))
|
||||
.map(({ path }) => path),
|
||||
...TECH_LOG_PUBLIC_FIXTURE_PATHS,
|
||||
]),
|
||||
];
|
||||
|
||||
test("production HTTP preserves the source in-shell Studio 404", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get("/studio/unknown-screen");
|
||||
|
||||
expect(response.status()).toBe(404);
|
||||
expect(response.headers()["content-type"]).toBe("text/html;charset=UTF-8");
|
||||
expect((await response.text()).startsWith("<!doctype html>")).toBe(true);
|
||||
});
|
||||
|
||||
for (const path of knownSpaPaths) {
|
||||
test(`production HTTP serves the SPA for ${path}`, async ({ request }) => {
|
||||
const response = await request.get(path);
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toBe("text/html;charset=UTF-8");
|
||||
expect((await response.text()).startsWith("<!doctype html>")).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const path of [
|
||||
"/projects/missing-project",
|
||||
"/definitely-not-a-product-route",
|
||||
]) {
|
||||
test(`production HTTP returns the source-exact raw 404 for ${path}`, async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(path);
|
||||
|
||||
expect(response.status()).toBe(404);
|
||||
expect(response.headers()["content-type"]).toBe(
|
||||
"text/plain;charset=UTF-8",
|
||||
);
|
||||
expect(await response.body()).toEqual(Buffer.from("Not Found", "utf8"));
|
||||
});
|
||||
}
|
||||
|
||||
for (const path of ["/config.json", "/release-manifest.json"]) {
|
||||
test(`production HTTP frames boot document ${path} like the reviewed preview`, async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(path);
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toBe("application/json");
|
||||
expect(response.headers()["cache-control"]).toBe("no-cache");
|
||||
expect(response.headers().vary).toBe("Origin");
|
||||
expect(Number(response.headers()["content-length"])).toBe(
|
||||
(await response.body()).byteLength,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -372,18 +372,20 @@ describe("TechLog explore discovery", () => {
|
||||
).toHaveAttribute("href", "/explore");
|
||||
});
|
||||
|
||||
it("renders an unknown kind as the source production plain 404", async () => {
|
||||
it("keeps the unreachable unknown-kind client fallback accessible", async () => {
|
||||
const { router, container } = renderDiscoveryRoute(
|
||||
"TECH_LOG_EXPLORE_KIND",
|
||||
"/explore/unknown",
|
||||
);
|
||||
|
||||
expect(router.state.location.pathname).toBe("/explore/unknown");
|
||||
expect(await screen.findByText("Not Found", { exact: true })).toBeVisible();
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".site-frame")).toBeNull();
|
||||
expect(container.querySelector(".site-frame")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("recovers a rejecting registered not-found runtime under its own chunk contract", async () => {
|
||||
|
||||
@@ -337,12 +337,14 @@ 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 source production plain 404 for %s", async (routeId, path) => {
|
||||
])("keeps the unreachable client fallback accessible for %s", async (routeId, path) => {
|
||||
const { container, router } = renderDocumentRoute(routeId, path);
|
||||
|
||||
expect(router.state.location.pathname).toBe(path);
|
||||
expect(await screen.findByText("Not Found", { exact: true })).toBeVisible();
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." })).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".site-frame")).toBeNull();
|
||||
expect(container.querySelector(".site-frame")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -360,25 +360,29 @@ 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"],
|
||||
])("renders the source-compatible plain fallback for %s", async (routeId, path) => {
|
||||
])("keeps the client-only fallback accessible for %s", async (routeId, path) => {
|
||||
const { container, router } = renderPublicRoute(routeId, path);
|
||||
|
||||
expect(router.state.location.pathname).toBe(path);
|
||||
expect(await screen.findByText("Not Found", { exact: true })).toBeVisible();
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".site-frame")).toBeNull();
|
||||
expect(container.querySelector(".site-frame")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the source-compatible plain fallback for the Public catch-all", () => {
|
||||
it("keeps the client-only Public catch-all accessible", () => {
|
||||
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();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
||||
).toBeVisible();
|
||||
expect(container.querySelector(".site-frame")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ function ignoredConsole(message: ConsoleMessage) {
|
||||
export const test = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
const failures: string[] = [];
|
||||
let mainDocumentStatus: number | null = null;
|
||||
const onConsole = (message: ConsoleMessage) => {
|
||||
if (
|
||||
["error", "warning"].includes(message.type()) &&
|
||||
@@ -31,14 +32,33 @@ export const test = base.extend({
|
||||
`requestfailed:${request.method()}:${new URL(request.url()).pathname}:${request.failure()?.errorText ?? "unknown"}`,
|
||||
);
|
||||
};
|
||||
const onResponse = (response: import("@playwright/test").Response) => {
|
||||
if (
|
||||
response.request().resourceType() === "document" &&
|
||||
response.frame() === page.mainFrame()
|
||||
) {
|
||||
mainDocumentStatus = response.status();
|
||||
}
|
||||
};
|
||||
page.on("console", onConsole);
|
||||
page.on("pageerror", onPageError);
|
||||
page.on("requestfailed", onRequestFailed);
|
||||
page.on("response", onResponse);
|
||||
await use(page);
|
||||
page.off("console", onConsole);
|
||||
page.off("pageerror", onPageError);
|
||||
page.off("requestfailed", onRequestFailed);
|
||||
expect(failures, "unexpected browser console/page errors").toEqual([]);
|
||||
page.off("response", onResponse);
|
||||
const expectedDocumentNotFoundConsole =
|
||||
"console:error:Failed to load resource: the server responded with a status of 404 (Not Found)";
|
||||
expect(
|
||||
failures.filter(
|
||||
(failure) =>
|
||||
failure !== expectedDocumentNotFoundConsole ||
|
||||
mainDocumentStatus !== 404,
|
||||
),
|
||||
"unexpected browser console/page errors",
|
||||
).toEqual([]);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -127,8 +127,12 @@ export async function settleTechLogPage(page: Page) {
|
||||
|
||||
export async function gotoTechLog(page: Page, path: string) {
|
||||
await prepareTechLogPage(page);
|
||||
await page.goto(path);
|
||||
const response = await page.goto(path, { waitUntil: "networkidle" });
|
||||
await page.locator("body").waitFor({ state: "visible" });
|
||||
if (response?.headers()["content-type"]?.startsWith("text/html")) {
|
||||
await page.locator("html[data-build-id][data-release-id]").waitFor();
|
||||
await page.locator(".site-frame, .studio-app").waitFor();
|
||||
}
|
||||
await settleTechLogPage(page);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// @ts-nocheck -- standalone evidence runner executed directly with tsx.
|
||||
// @ts-nocheck -- standalone evidence runner executed directly by Node 24.
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { lstat, mkdir, readFile, readdir, readlink, writeFile } from "node:fs/promises";
|
||||
import { relative, resolve } from "node:path";
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
TECH_LOG_BREAKPOINT_WIDTHS,
|
||||
TECH_LOG_CANONICAL_ROUTES,
|
||||
TECH_LOG_FIXED_TIME,
|
||||
TECH_LOG_PUBLIC_FIXTURE_PATHS,
|
||||
TECH_LOG_STUDIO_STATE_PATHS,
|
||||
TECH_LOG_UNKNOWN_PUBLIC_PATHS,
|
||||
} from "./tech-log-fixtures.ts";
|
||||
@@ -18,9 +21,10 @@ 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 sourceBaseUrl = process.env.TECH_LOG_SOURCE_URL ?? "http://127.0.0.1:4375";
|
||||
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 sourceRoot = process.env.TECH_LOG_SOURCE_ROOT ?? "/home/donghyeon/workspace/techlog-studio-frontend";
|
||||
const outputPath = process.env.TECH_LOG_PARITY_OUTPUT ?? "docs/operations/evidence/tech-log-source-parity.json";
|
||||
const fixedDate = new Date(TECH_LOG_FIXED_TIME);
|
||||
const noMotionCss = `
|
||||
*, *::before, *::after {
|
||||
@@ -35,6 +39,11 @@ const noMotionCss = `
|
||||
const allCases = [
|
||||
...TECH_LOG_CANONICAL_ROUTES.flatMap(({ routeId, path }) =>
|
||||
[360, 1440].map((width) => ({ name: `${routeId}-${width}`, path, width }))),
|
||||
...TECH_LOG_PUBLIC_FIXTURE_PATHS.map((path) => ({
|
||||
name: `TECH_LOG_PUBLIC_FIXTURE-${path.slice(1).replaceAll("/", "-").toUpperCase()}`,
|
||||
path,
|
||||
width: 1440,
|
||||
})),
|
||||
...TECH_LOG_BREAKPOINT_WIDTHS.map((width) => ({
|
||||
name: `TECH_LOG_HOME-BREAKPOINT-${width}`,
|
||||
path: "/",
|
||||
@@ -117,45 +126,123 @@ async function interact(page, action) {
|
||||
}
|
||||
}
|
||||
|
||||
async function settle(page, baseUrl, parityCase) {
|
||||
async function settle(page, baseUrl, parityCase, waitForTargetBootstrap) {
|
||||
page.setDefaultNavigationTimeout(15_000);
|
||||
page.setDefaultTimeout(15_000);
|
||||
await page.setViewportSize({ width: parityCase.width, height: 1000 });
|
||||
await page.clock.setFixedTime(fixedDate);
|
||||
await page.goto(new URL(parityCase.path, baseUrl).href, { waitUntil: "networkidle" });
|
||||
const navigationResponse = await page.goto(
|
||||
new URL(parityCase.path, baseUrl).href,
|
||||
{ waitUntil: "networkidle" },
|
||||
);
|
||||
if (!navigationResponse) throw new Error(`Missing navigation response: ${parityCase.name}`);
|
||||
const response = await responseMetadata(navigationResponse);
|
||||
if (response.mimeType === "text/html" && waitForTargetBootstrap) {
|
||||
// The target sets these only after config + release-manifest bodies have
|
||||
// been fully read, validated, and composed. This is the product's boot
|
||||
// completion signal, not a delay or retry.
|
||||
await page.locator("html[data-build-id][data-release-id]").waitFor();
|
||||
}
|
||||
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);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function projection(page) {
|
||||
async function productSubtree(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),
|
||||
})),
|
||||
}));
|
||||
const normalize = (value) => value
|
||||
.replace(/_[rR][^\s"']*?(?=-|\s|$)/g, "<generated-id>")
|
||||
.replace(/_([A-Za-z0-9]+)_[A-Za-z0-9]+_(\d+)/g, "_$1_<generated-module>_$2");
|
||||
const frameworkGeneratedAttributes = new Set([
|
||||
"data-discover", // React Router link discovery metadata.
|
||||
"data-nimg", // Next/Image metadata derived from source props.
|
||||
"decoding", // Next/Image output derived from priority=false.
|
||||
"srcset", // Next/Image widths derived from one source SVG.
|
||||
"selected", // Next SSR output for a controlled <select> value.
|
||||
]);
|
||||
const serialize = (node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.nodeValue?.replace(/\s+/g, " ").trim() ?? "";
|
||||
return text ? { type: "text", text } : null;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return null;
|
||||
const element = node;
|
||||
return {
|
||||
type: "element",
|
||||
tag: element.tagName.toLowerCase(),
|
||||
classes: [...element.classList].map(normalize),
|
||||
attributes: [...element.attributes]
|
||||
.filter(({ name }) =>
|
||||
name !== "class" && !frameworkGeneratedAttributes.has(name))
|
||||
.map(({ name, value }) => [name, normalize(value)])
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
children: [...element.childNodes].map(serialize).filter(Boolean),
|
||||
};
|
||||
};
|
||||
const root = document.querySelector(".site-frame, .studio-app") ?? document.body;
|
||||
return serialize(root);
|
||||
});
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
async function responseMetadata(response) {
|
||||
const body = await response.body();
|
||||
const contentType = response.headers()["content-type"] ?? "";
|
||||
return {
|
||||
status: response.status(),
|
||||
contentType,
|
||||
mimeType: contentType.split(";", 1)[0].toLowerCase(),
|
||||
bodyBytes: body.byteLength,
|
||||
bodySha256: sha256(body),
|
||||
};
|
||||
}
|
||||
|
||||
function responseContractEqual(source, target) {
|
||||
if (source.status !== target.status || source.mimeType !== target.mimeType) return false;
|
||||
if (source.status !== 404 || source.mimeType === "text/html") return true;
|
||||
return source.contentType === target.contentType &&
|
||||
source.bodyBytes === 9 &&
|
||||
target.bodyBytes === 9 &&
|
||||
source.bodySha256 === target.bodySha256 &&
|
||||
source.bodySha256 === sha256("Not Found");
|
||||
}
|
||||
|
||||
const excludedSourceDirectories = new Set([
|
||||
".git", ".next", ".sites-runtime", ".vinext", ".turbo", "artifacts", "coverage", "dist", "node_modules",
|
||||
]);
|
||||
|
||||
async function sourceChecksums(root) {
|
||||
const checksums = [];
|
||||
async function walk(directory) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && excludedSourceDirectories.has(entry.name)) continue;
|
||||
const absolutePath = resolve(directory, entry.name);
|
||||
const sourcePath = relative(root, absolutePath).replaceAll("\\", "/");
|
||||
if (entry.isDirectory()) {
|
||||
await walk(absolutePath);
|
||||
} else if (entry.isSymbolicLink()) {
|
||||
const target = await readlink(absolutePath);
|
||||
checksums.push({ path: sourcePath, bytes: Buffer.byteLength(target), sha256: sha256(target), kind: "symlink" });
|
||||
} else if ((await lstat(absolutePath)).isFile()) {
|
||||
const bytes = await readFile(absolutePath);
|
||||
checksums.push({ path: sourcePath, bytes: bytes.byteLength, sha256: sha256(bytes), kind: "file" });
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(resolve(root));
|
||||
checksums.sort((left, right) => left.path.localeCompare(right.path));
|
||||
return {
|
||||
files: checksums,
|
||||
digest: sha256(checksums.map(({ path, bytes, sha256: digest, kind }) => `${kind}\0${path}\0${bytes}\0${digest}\n`).join("")),
|
||||
};
|
||||
}
|
||||
|
||||
async function layoutProjection(page) {
|
||||
return page.evaluate(() => [...document.querySelectorAll("header, main, main *, footer, dialog, .studio-app *")].map((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
@@ -194,6 +281,44 @@ function pixelDifference(sourceBuffer, targetBuffer) {
|
||||
return { pixels, sourceSize: [source.width, source.height], targetSize: [target.width, target.height] };
|
||||
}
|
||||
|
||||
function observePage(page, errors) {
|
||||
const bootRequests = [];
|
||||
const records = new Map();
|
||||
const isBootRequest = (request) => {
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
return pathname === "/config.json" || pathname === "/release-manifest.json";
|
||||
};
|
||||
page.on("request", (request) => {
|
||||
if (!isBootRequest(request)) return;
|
||||
const record = {
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
resourceType: request.resourceType(),
|
||||
state: "started",
|
||||
};
|
||||
records.set(request, record);
|
||||
bootRequests.push(record);
|
||||
});
|
||||
page.on("requestfinished", (request) => {
|
||||
const record = records.get(request);
|
||||
if (record) record.state = "finished";
|
||||
});
|
||||
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) => {
|
||||
const failure = request.failure()?.errorText ?? "";
|
||||
const record = records.get(request);
|
||||
if (record) {
|
||||
record.state = "failed";
|
||||
record.failure = failure;
|
||||
}
|
||||
errors.push(`requestfailed: ${request.resourceType()} ${request.url()} ${failure}`);
|
||||
});
|
||||
return bootRequests;
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const contextOptions = {
|
||||
colorScheme: "light",
|
||||
@@ -203,78 +328,123 @@ const contextOptions = {
|
||||
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 sourceContext = await browser.newContext(contextOptions);
|
||||
const targetContext = await browser.newContext(contextOptions);
|
||||
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`),
|
||||
const sourceBootRequests = observePage(sourcePage, sourceErrors);
|
||||
const targetBootRequests = observePage(targetPage, targetErrors);
|
||||
try {
|
||||
const [sourceResponse, targetResponse] = await Promise.all([
|
||||
settle(sourcePage, sourceBaseUrl, parityCase, false),
|
||||
settle(targetPage, targetBaseUrl, parityCase, true),
|
||||
]);
|
||||
const [sourceShot, targetShot, sourceTree, targetTree, sourceLayout, targetLayout, sourceFonts, targetFonts] = await Promise.all([
|
||||
sourcePage.screenshot({ fullPage: true }),
|
||||
targetPage.screenshot({ fullPage: true }),
|
||||
productSubtree(sourcePage),
|
||||
productSubtree(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 sourceTreeJson = JSON.stringify(sourceTree);
|
||||
const targetTreeJson = JSON.stringify(targetTree);
|
||||
const structureEqual = sourceTreeJson === targetTreeJson;
|
||||
const fontsEqual = JSON.stringify(sourceFonts) === JSON.stringify(targetFonts);
|
||||
const responseEqual = responseContractEqual(sourceResponse, targetResponse);
|
||||
const isPlainNotFound = sourceResponse.mimeType === "text/plain";
|
||||
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 && structureEqual && responseEqual && (!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({ sourceTree, targetTree, sourceLayout, targetLayout }, null, 2)}\n`),
|
||||
]);
|
||||
}
|
||||
results.push({
|
||||
name: parityCase.name,
|
||||
path: parityCase.path,
|
||||
width: parityCase.width,
|
||||
action: parityCase.action ?? null,
|
||||
pixel,
|
||||
screenshotSha256: { source: sha256(sourceShot), target: sha256(targetShot) },
|
||||
structureEqual,
|
||||
productSubtreeSha256: { source: sha256(sourceTreeJson), target: sha256(targetTreeJson) },
|
||||
responseEqual,
|
||||
response: { source: sourceResponse, target: targetResponse },
|
||||
fontsEqual,
|
||||
fontContractApplies,
|
||||
sourceFonts,
|
||||
targetFonts,
|
||||
sourceErrors,
|
||||
targetErrors,
|
||||
sourceBootRequests,
|
||||
targetBootRequests,
|
||||
unexplainedSourceErrors,
|
||||
unexplainedTargetErrors,
|
||||
passed,
|
||||
});
|
||||
console.log(`${passed ? "PASS" : "FAIL"} ${parityCase.name} pixels=${pixel.pixels ?? "SIZE"} tree=${structureEqual} response=${responseEqual} fonts=${fontContractApplies ? fontsEqual : "source-plain-text"} errors=${unexplainedSourceErrors.length}/${unexplainedTargetErrors.length}`);
|
||||
} finally {
|
||||
await Promise.all([sourceContext.close(), targetContext.close()]);
|
||||
}
|
||||
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 = {
|
||||
const [sourceTree, buildManifest, viteManifest] = await Promise.all([
|
||||
sourceChecksums(sourceRoot),
|
||||
readFile("artifacts/release/build-manifest.json"),
|
||||
readFile("dist/.vite/manifest.json"),
|
||||
]);
|
||||
const evidenceWithoutDigest = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
sourceBaseUrl,
|
||||
targetBaseUrl,
|
||||
conditions: { ...contextOptions, fixedTime: TECH_LOG_FIXED_TIME, viewportHeight: 1000, screenshots: "fullPage", masks: 0 },
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
baseUrl: sourceBaseUrl,
|
||||
root: sourceRoot,
|
||||
treeSha256: sourceTree.digest,
|
||||
checksums: sourceTree.files,
|
||||
},
|
||||
target: {
|
||||
baseUrl: targetBaseUrl,
|
||||
commit: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
|
||||
buildManifestSha256: sha256(buildManifest),
|
||||
viteManifestSha256: sha256(viteManifest),
|
||||
},
|
||||
conditions: { ...contextOptions, fixedTime: TECH_LOG_FIXED_TIME, viewportHeight: 1000, screenshots: "fullPage", masks: 0, freshSourceAndTargetContextsPerCase: true },
|
||||
caseInventory: allCases,
|
||||
caseInventorySha256: sha256(JSON.stringify(allCases)),
|
||||
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,
|
||||
};
|
||||
const evidence = {
|
||||
...evidenceWithoutDigest,
|
||||
evidenceSha256: sha256(JSON.stringify(evidenceWithoutDigest)),
|
||||
};
|
||||
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 }));
|
||||
|
||||
@@ -192,14 +192,35 @@ describe("CI gate contract", () => {
|
||||
expect(contract.commands).toHaveLength(81);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(93);
|
||||
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(85);
|
||||
expect(contract.artifacts).toHaveLength(105);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(106);
|
||||
expect(contract.artifacts).toHaveLength(126);
|
||||
expect(contract.stages).toHaveLength(5);
|
||||
expect(contract.retention.classes).toHaveLength(5);
|
||||
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
|
||||
expect(index.gates.get("FE-GATE-020")?.name).toBe("removability");
|
||||
});
|
||||
|
||||
it("rejects accessibility evidence registrations that drift from the installed route scope", async () => {
|
||||
const candidate = JSON.parse(
|
||||
JSON.stringify(await loadCiGateContract(process.cwd())),
|
||||
) as Record<string, any>;
|
||||
const gate = candidate.gates.find(
|
||||
(entry: Record<string, any>) => entry.id === "FE-GATE-009",
|
||||
);
|
||||
const artifactId =
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-HOME-md";
|
||||
candidate.artifacts = candidate.artifacts.filter(
|
||||
(entry: Record<string, any>) => entry.id !== artifactId,
|
||||
);
|
||||
gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter(
|
||||
(id: string) => id !== artifactId,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
parseCiGateContract(candidate, { mode: "removal-fixture" }),
|
||||
).toThrow(/FE-GATE-009 manual accessibility evidence.*installed route scope/i);
|
||||
});
|
||||
|
||||
it("keeps the action registry recursively immutable and resolves only known actions", () => {
|
||||
expect(Object.isFrozen(CI_ACTION_REGISTRY)).toBe(true);
|
||||
expect(Object.values(CI_ACTION_REGISTRY).every((action) => Object.isFrozen(action))).toBe(true);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
const reviewed = `Status: reviewed
|
||||
Route ID: APP_HOME
|
||||
Route ID: TECH_LOG_HOME
|
||||
Release ID: release-1
|
||||
Reviewer: reviewer@example.test
|
||||
Reviewed at: 2026-07-25T12:00:00Z
|
||||
|
||||
@@ -148,7 +148,7 @@ describe("selective Task 3 contract closure", () => {
|
||||
expect(canonical.gates).toHaveLength(26);
|
||||
expect(canonical.commands).toHaveLength(81);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(93);
|
||||
expect(canonical.artifacts).toHaveLength(105);
|
||||
expect(canonical.artifacts).toHaveLength(126);
|
||||
expect(canonical.stages).toHaveLength(5);
|
||||
expect(canonical.retention.classes).toHaveLength(5);
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("TechLog source parity evidence harness", () => {
|
||||
it("is a checked Node runner with fixture, response, recursive tree, and provenance coverage", async () => {
|
||||
const [packageJson, source] = await Promise.all([
|
||||
readFile("package.json", "utf8").then(JSON.parse),
|
||||
readFile("tests/support/browser/verify-tech-log-source-parity.ts", "utf8"),
|
||||
]);
|
||||
|
||||
expect(packageJson.scripts["verify:tech-log-source-parity"]).toBe(
|
||||
"node tests/support/browser/verify-tech-log-source-parity.ts",
|
||||
);
|
||||
expect(source).toContain("TECH_LOG_PUBLIC_FIXTURE_PATHS.map");
|
||||
expect(source).toContain("element.childNodes");
|
||||
expect(source).toContain("responseContractEqual");
|
||||
expect(source).toContain("sourceChecksums(sourceRoot)");
|
||||
expect(source).toContain("caseInventorySha256");
|
||||
expect(source).toContain("evidenceSha256");
|
||||
expect(source).toContain(
|
||||
'"docs/operations/evidence/tech-log-source-parity.json"',
|
||||
);
|
||||
expect(source).not.toContain("tsx");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { writeTechLogServingArtifact } from "../../scripts/lib/tech-log-serving-artifact.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true })));
|
||||
});
|
||||
|
||||
describe("TechLog serving artifact", () => {
|
||||
it("emits a self-contained deployment server and its route contract", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "tech-log-serving-artifact-"));
|
||||
roots.push(root);
|
||||
const contract = {
|
||||
schemaVersion: 1 as const,
|
||||
publicSpaPaths: ["/", "/projects/auth-lab"],
|
||||
studioPathPrefix: "/studio" as const,
|
||||
studioSpaPathPatterns: ["^/studio$"],
|
||||
notFound: {
|
||||
status: 404 as const,
|
||||
contentType: "text/plain;charset=UTF-8" as const,
|
||||
body: "Not Found" as const,
|
||||
},
|
||||
};
|
||||
|
||||
await writeTechLogServingArtifact({ distRoot: root, contract });
|
||||
|
||||
expect(
|
||||
JSON.parse(
|
||||
await readFile(path.join(root, "tech-log-serving-contract.json"), "utf8"),
|
||||
),
|
||||
).toEqual(contract);
|
||||
const serverModule = await import(
|
||||
`${pathToFileURL(path.join(root, "server.mjs")).href}?test=${Date.now()}`
|
||||
);
|
||||
expect(serverModule.createTechLogProductionServer).toEqual(expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTechLogServingContract } from "../../scripts/lib/tech-log-serving-contract.ts";
|
||||
import {
|
||||
projects,
|
||||
publicRecords,
|
||||
releases,
|
||||
} from "../../src/features/tech-log/adapters/static/public-content.ts";
|
||||
|
||||
describe("TechLog production serving contract", () => {
|
||||
it("derives every known Public path from the installed static content", () => {
|
||||
const contract = createTechLogServingContract({
|
||||
projects,
|
||||
publicRecords,
|
||||
releases,
|
||||
});
|
||||
|
||||
expect(contract.publicSpaPaths).toEqual([
|
||||
"/",
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/cases/redis-adapter-ttl-boundary",
|
||||
"/explore",
|
||||
"/explore/cases",
|
||||
"/explore/questions",
|
||||
"/explore/references",
|
||||
"/profile",
|
||||
"/projects",
|
||||
"/projects/auth-lab",
|
||||
"/projects/auth-lab/activity",
|
||||
"/projects/auth-lab/decisions",
|
||||
"/projects/auth-lab/records",
|
||||
"/projects/backend-skeleton",
|
||||
"/projects/backend-skeleton/activity",
|
||||
"/projects/backend-skeleton/decisions",
|
||||
"/projects/backend-skeleton/records",
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
"/questions/validate-edge-token-again",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
"/references/state-and-nonce-boundary",
|
||||
"/releases",
|
||||
"/releases/0.1.0",
|
||||
"/search",
|
||||
"/topics/authentication",
|
||||
"/topics/jpa",
|
||||
"/topics/redis",
|
||||
]);
|
||||
expect(contract.studioPathPrefix).toBe("/studio");
|
||||
expect(contract.studioSpaPathPatterns).toEqual([
|
||||
"^/studio$",
|
||||
"^/studio/documents$",
|
||||
"^/studio/documents/new$",
|
||||
"^/studio/documents/[^/]+/(edit|validation|preview|publish)$",
|
||||
"^/studio/publications$",
|
||||
"^/studio/publications/[^/]+/preview$",
|
||||
]);
|
||||
expect(contract.notFound).toEqual({
|
||||
status: 404,
|
||||
contentType: "text/plain;charset=UTF-8",
|
||||
body: "Not Found",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 687 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 28 KiB |
@@ -3,10 +3,15 @@ import {
|
||||
gotoTechLog,
|
||||
TECH_LOG_BREAKPOINT_WIDTHS,
|
||||
TECH_LOG_CANONICAL_ROUTES,
|
||||
TECH_LOG_PUBLIC_FIXTURE_PATHS,
|
||||
TECH_LOG_STUDIO_STATE_PATHS,
|
||||
TECH_LOG_UNKNOWN_PUBLIC_PATHS,
|
||||
} from "../support/browser/tech-log-fixtures.ts";
|
||||
|
||||
function fixtureSnapshotName(path: string) {
|
||||
return `tech-log-public-fixture-${path.slice(1).replaceAll("/", "-")}-1440.png`;
|
||||
}
|
||||
|
||||
for (const route of TECH_LOG_CANONICAL_ROUTES) {
|
||||
for (const width of [360, 1440] as const) {
|
||||
test(`${route.routeId} matches the reviewed ${width}px visual`, async ({ page }) => {
|
||||
@@ -19,6 +24,18 @@ for (const route of TECH_LOG_CANONICAL_ROUTES) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of TECH_LOG_PUBLIC_FIXTURE_PATHS) {
|
||||
test(`known Public fixture ${path} matches its reviewed wide visual`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await gotoTechLog(page, path);
|
||||
await expect(page).toHaveScreenshot(fixtureSnapshotName(path), {
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of TECH_LOG_BREAKPOINT_WIDTHS) {
|
||||
test(`Public home matches the reviewed ${width}px breakpoint visual`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
|
||||