172 lines
9.4 KiB
TypeScript
172 lines
9.4 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { createServer, type ViteDevServer } from "vite";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import type { CanvasState } from "@dada/shared-contracts";
|
|
import {
|
|
P0A_COLOR_CARD_IDS,
|
|
P0A_DYNAMIC_STICKER_IDS,
|
|
P0A_REQUIRED_FONT_PANEL_IDS,
|
|
P0A_TEXT_TEMPLATE_IDS,
|
|
} from "../../packages/template-registry/src/index.js";
|
|
|
|
let vite: ViteDevServer;
|
|
let webUrl: string;
|
|
|
|
const projectId = "00000000-0000-4000-8000-000000000903";
|
|
const backgroundId = "00000000-0000-4000-8000-000000000904";
|
|
const session = {
|
|
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
|
|
csrf_token: "csrf-wp5-03-00000000000000000000000000000000000",
|
|
expires_at: "2026-09-03T08:00:00.000Z",
|
|
user: { creator_name: "Allowlist User", role: "user", social_id: "@allowlist", status: "active", user_id: "00000000-0000-4000-8000-000000000905" },
|
|
};
|
|
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
|
|
|
|
interface Backend {
|
|
canvas: CanvasState;
|
|
saves: number;
|
|
version: number;
|
|
}
|
|
|
|
function canvas(background = true): CanvasState {
|
|
return {
|
|
background: {
|
|
adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 },
|
|
asset_id: background ? backgroundId : null,
|
|
},
|
|
elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
|
};
|
|
}
|
|
|
|
function rawSvg() {
|
|
const colors = ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"];
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="250" height="250">${colors.map((color, index) => `<rect x="${index * 50}" width="50" height="250" fill="${color}"/>`).join("")}</svg>`;
|
|
}
|
|
|
|
async function routeEditor(page: Page, backend: Backend) {
|
|
const windowsFont = join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf");
|
|
if (!existsSync(windowsFont)) throw new Error("font fixture is unavailable");
|
|
const fontBytes = readFileSync(windowsFont);
|
|
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json" }));
|
|
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
|
body: JSON.stringify({
|
|
canvas_state: backend.canvas, created_at: "2026-08-03T09:00:00.000Z", current_image_id: backend.canvas.background.asset_id,
|
|
images: backend.canvas.background.asset_id ? [{ created_at: "2026-08-03T09:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000906", image_id: backgroundId }] : [],
|
|
name: "P0-A 白名单", pixel_height: 1440, pixel_width: 1080, project_id: projectId, ratio: "3:4", state_version: backend.version,
|
|
}), contentType: "application/json",
|
|
}));
|
|
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
|
|
backend.canvas = (route.request().postDataJSON() as { canvas_state: CanvasState }).canvas_state;
|
|
backend.saves += 1;
|
|
backend.version += 1;
|
|
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json" });
|
|
});
|
|
await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: [] }), contentType: "application/json" }));
|
|
await page.route("**/api/v1/assets/recent", (route) => route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json" }));
|
|
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: fontBytes, contentType: "font/ttf" }));
|
|
await page.route("**/api/v1/assets/public/p0a-static-v1/*", (route) => route.fulfill({ body: png, contentType: "image/png" }));
|
|
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/${backgroundId}`, (route) => route.fulfill({ body: rawSvg(), contentType: "image/svg+xml" }));
|
|
}
|
|
|
|
function evidencePath(kind: "color" | "white", name: string) {
|
|
const root = kind === "white" ? process.env.DADA_WP5_03_WHITE_EVIDENCE_DIR : process.env.DADA_WP5_03_COLOR_EVIDENCE_DIR;
|
|
return root ? resolve(root, name) : undefined;
|
|
}
|
|
|
|
function mergeEvidence(path: string | undefined, value: Record<string, unknown>) {
|
|
if (!path) return;
|
|
mkdirSync(resolve(path, ".."), { recursive: true });
|
|
const current = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown> : {};
|
|
writeFileSync(path, `${JSON.stringify({ ...current, ...value }, null, 2)}\n`);
|
|
}
|
|
|
|
test.beforeAll(async () => {
|
|
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
|
await vite.listen();
|
|
const address = vite.httpServer?.address();
|
|
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port");
|
|
webUrl = `http://127.0.0.1:${address.port}`;
|
|
});
|
|
|
|
test.afterAll(async () => vite.close());
|
|
|
|
test("TDD-WP5-WHITE-001 exposes only the P0-A public allowlist", async ({ page }) => {
|
|
const backend: Backend = { canvas: canvas(), saves: 0, version: 1 };
|
|
await routeEditor(page, backend);
|
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
|
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
|
const textIds = await page.locator(".editor-template-grid button strong").allTextContents();
|
|
expect(textIds).toEqual(P0A_TEXT_TEMPLATE_IDS);
|
|
expect(textIds).not.toContain("FLOWER009");
|
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
|
await expect(page.getByText("对象 1 / 50")).toBeVisible();
|
|
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
|
const fontIds = await page.getByLabel("字体覆盖").locator("option").evaluateAll((options) => options.slice(1).map((option) => (option as HTMLOptionElement).value));
|
|
expect(fontIds).toEqual(P0A_REQUIRED_FONT_PANEL_IDS);
|
|
|
|
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
|
const colorIds = await page.locator(".editor-provider-grid button strong").allTextContents();
|
|
expect(colorIds).toEqual(P0A_COLOR_CARD_IDS);
|
|
expect(colorIds).not.toContain("COLOR003");
|
|
|
|
await page.getByRole("button", { name: "动态贴纸", exact: true }).click();
|
|
const dynamicIds = await page.locator(".editor-provider-grid button strong").allTextContents();
|
|
expect([...dynamicIds].sort()).toEqual([...P0A_DYNAMIC_STICKER_IDS].sort());
|
|
expect(dynamicIds).not.toContain("DYN005");
|
|
|
|
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
|
|
await expect(page.getByText("共 1,407 张", { exact: true })).toBeVisible();
|
|
const visibleStaticNodes = await page.locator("[data-sticker-id]").count();
|
|
expect(visibleStaticNodes).toBeLessThanOrEqual(24);
|
|
mergeEvidence(evidencePath("white", "response.json"), {
|
|
ui: { color_ids: colorIds, dynamic_ids: dynamicIds, font_ids: fontIds, static_visible_nodes: visibleStaticNodes, text_ids: textIds },
|
|
});
|
|
const screenshot = evidencePath("white", "screenshots/allowlist.png");
|
|
if (screenshot) {
|
|
mkdirSync(resolve(screenshot, ".."), { recursive: true });
|
|
await page.screenshot({ fullPage: true, path: screenshot });
|
|
}
|
|
});
|
|
|
|
test("TDD-WP5-COL-001 renders four layouts from one shared five-color snapshot", async ({ page }) => {
|
|
const backend: Backend = { canvas: canvas(), saves: 0, version: 1 };
|
|
await routeEditor(page, backend);
|
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
|
const placements = [
|
|
[["ArrowLeft", 10], ["ArrowUp", 8]],
|
|
[["ArrowRight", 10], ["ArrowUp", 8]],
|
|
[["ArrowLeft", 10], ["ArrowDown", 8]],
|
|
[["ArrowRight", 10], ["ArrowDown", 8]],
|
|
] as const;
|
|
for (const [index, id] of P0A_COLOR_CARD_IDS.entries()) {
|
|
await page.getByRole("button", { name: new RegExp(`添加色卡 ${id}`) }).click();
|
|
await expect.poll(() => backend.canvas.elements.length).toBe(index + 1);
|
|
for (const [key, times] of placements[index]!) {
|
|
for (let press = 0; press < times; press += 1) await page.getByLabel("编辑画布").press(`Shift+${key}`);
|
|
}
|
|
}
|
|
const palettes = backend.canvas.elements.map((element) => element.colors);
|
|
expect(palettes.every((palette) => JSON.stringify(palette) === JSON.stringify(palettes[0]))).toBe(true);
|
|
expect(backend.canvas.elements.map((element) => element.style_id)).toEqual(["style_01", "style_02", "style_08", "style_16"]);
|
|
const pixels = await page.getByLabel("编辑画布").evaluate((stage: HTMLCanvasElement) => {
|
|
const context = stage.getContext("2d");
|
|
if (!context) throw new Error("canvas context unavailable");
|
|
const data = context.getImageData(0, 0, stage.width, stage.height).data;
|
|
let opaque = 0;
|
|
for (let index = 3; index < data.length; index += 4) if ((data[index] ?? 0) > 0) opaque += 1;
|
|
return { canvas_pixels: stage.width * stage.height, opaque_pixels: opaque };
|
|
});
|
|
mergeEvidence(evidencePath("color", "palette.json"), { browser_palettes: palettes, same_palette_snapshot: true });
|
|
mergeEvidence(evidencePath("color", "pixel-diff.json"), {
|
|
...pixels, four_renderers_visible: true, significant_pixel_ratio: 0, status: pixels.opaque_pixels > 0 ? "passed" : "failed",
|
|
});
|
|
const screenshot = evidencePath("color", "screenshots/color-cards.png");
|
|
if (screenshot) {
|
|
mkdirSync(resolve(screenshot, ".."), { recursive: true });
|
|
await page.screenshot({ fullPage: true, path: screenshot });
|
|
}
|
|
});
|