e2e/project-library.spec.ts
Rama main · réplica de lectura del repositorio original.
import { navigateWorkspace } from "./workspace-controls";
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type APIRequestContext, type Locator, type Page } from "@playwright/test";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp, bearerRecord } from "../apps/server/dist/app.js";
const token = randomBytes(32).toString("hex");
let app: Awaited<ReturnType<typeof createApp>>;
let origin: string;
test.beforeAll(async () => {
// Exercise the actual bearer and session boundaries on an isolated demo host.
app = await createApp({ demo: true, logger: false, auth: { sessionSecret: randomBytes(48).toString("hex"), bearerTokens: [bearerRecord(token, ["read", "write"], undefined, "synthetic-image-agent")] } });
origin = await app.listen({ host: "127.0.0.1", port: 0 });
});
test.afterAll(async () => { await app?.close(); });
async function mcp<T = Record<string, unknown>>(request: APIRequestContext, name: string, arguments_: object): Promise<T> {
const response = await request.post(`${origin}/api/v1/mcp`, {
headers: { authorization: `Bearer ${token}`, "idempotency-key": randomUUID() },
data: { jsonrpc: "2.0", id: randomUUID(), method: "tools/call", params: { name, arguments: arguments_ } },
});
expect(response.status(), await response.text()).toBe(200);
const body = await response.json() as { error?: unknown; result?: { isError?: boolean; structuredContent?: T } };
expect(body.error).toBeUndefined();
expect(body.result?.isError, JSON.stringify(body.result?.structuredContent)).not.toBe(true);
expect(body.result?.structuredContent).toBeDefined();
return body.result!.structuredContent!;
}
async function createProject(request: APIRequestContext, name: string, description = "Synthetic maker project for the project-image regression.") {
const id = `gallery-${randomUUID()}`, revision = `${id}-r1`;
await mcp(request, "create_project_with_initial_revision", { name, projectId: id, revisionId: revision, description, revisionSummary: "Initial design", fabricationRoute: "printed" });
return { id, revision, name };
}
async function uploadImage(request: APIRequestContext, project: { id: string; revision: string }, bytes: Buffer, filename = "synthetic-enclosure-render.png") {
const headers = { authorization: `Bearer ${token}`, "idempotency-key": randomUUID() };
const sha256 = createHash("sha256").update(bytes).digest("hex");
const begin = await request.post(`${origin}/api/v1/artifacts/uploads`, { headers, data: { projectId: project.id, projectRevisionId: project.revision, filename, role: "photo", mediaType: "image/png", byteSize: bytes.length, sha256 } });
expect(begin.status(), await begin.text()).toBe(201);
const session = (await begin.json()).data as { id: string };
const write = await request.put(`${origin}/api/v1/artifacts/uploads/${session.id}`, { headers: { authorization: `Bearer ${token}`, "content-type": "application/octet-stream" }, data: bytes });
expect(write.status(), await write.text()).toBe(200);
const finalize = await request.post(`${origin}/api/v1/artifacts/uploads/${session.id}/finalize`, { headers: { ...headers, "idempotency-key": randomUUID() }, data: {} });
expect(finalize.status(), await finalize.text()).toBe(200);
const file = (await finalize.json()).data as { id: string; sha256: string };
expect(file.sha256).toBe(sha256);
return file;
}
async function signIn(page: Page) {
await page.goto(origin);
await page.getByLabel("Workspace password").fill("demo-password-please-change");
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page.getByRole("heading", { name: "Projects", exact: true })).toBeVisible();
}
function projectCard(page: Page, name: string) {
return page.locator(".home-project-row").filter({ has: page.getByRole("button", { name: `Open project ${name}`, exact: true }) });
}
async function expectProjectImageEditor(page: Page) {
await expect(page.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(page.getByRole("button", { name: "Project image", exact: true })).toHaveAttribute("aria-expanded", "true");
const editor = page.getByRole("region", { name: "Project image", exact: true });
await expect(editor).toBeVisible();
return editor;
}
async function openProjectImageEditor(page: Page) {
const disclosure = page.getByRole("button", { name: "Project image", exact: true });
await expect(disclosure).toBeVisible();
if (await disclosure.getAttribute("aria-expanded") !== "true") await disclosure.click();
return expectProjectImageEditor(page);
}
async function expectDecodedImage(image: Locator) {
await expect(image).toBeVisible();
await expect.poll(() => image.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(720);
}
async function syntheticProductImage(page: Page): Promise<Buffer> {
// A recognisable synthetic test image catches broken decoding and layout;
// this illustration is never presented as a manufactured or validated object.
const base64 = await page.evaluate(() => {
const canvas = document.createElement("canvas");
canvas.width = 720; canvas.height = 480;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#e7ecec"; ctx.fillRect(0, 0, 720, 480);
ctx.fillStyle = "#152c34"; ctx.font = "bold 24px sans-serif";
ctx.fillText("SYNTHETIC ENCLOSURE", 38, 49);
ctx.font = "17px sans-serif"; ctx.fillText("Project image fixture · design concept", 38, 79);
ctx.fillStyle = "#c2ccce"; ctx.beginPath(); ctx.ellipse(382, 363, 235, 39, 0, 0, Math.PI * 2); ctx.fill();
const front = ctx.createLinearGradient(170, 220, 550, 360);
front.addColorStop(0, "#e9bd73"); front.addColorStop(1, "#b47937");
ctx.fillStyle = front; ctx.beginPath(); ctx.roundRect(170, 217, 352, 132, 20); ctx.fill();
ctx.fillStyle = "#925d2d"; ctx.beginPath(); ctx.moveTo(520, 225); ctx.lineTo(589, 176); ctx.lineTo(589, 294); ctx.quadraticCurveTo(589, 310, 578, 318); ctx.lineTo(509, 350); ctx.closePath(); ctx.fill();
ctx.fillStyle = "#f1d399"; ctx.beginPath(); ctx.moveTo(175, 220); ctx.lineTo(245, 161); ctx.lineTo(571, 161); ctx.quadraticCurveTo(592, 161, 589, 178); ctx.lineTo(520, 229); ctx.closePath(); ctx.fill();
ctx.strokeStyle = "#bb8c4f"; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(189, 218); ctx.lineTo(252, 174); ctx.lineTo(561, 174); ctx.stroke();
ctx.fillStyle = "#263d45"; ctx.beginPath(); ctx.roundRect(203, 249, 155, 46, 9); ctx.fill();
ctx.fillStyle = "#63c6bd"; ctx.beginPath(); ctx.arc(472, 273, 12, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#243b42"; ctx.font = "18px sans-serif"; ctx.fillText("ENCLOSURE / R01", 212, 330);
ctx.font = "16px sans-serif"; ctx.fillText("Test fixture only", 38, 443);
return canvas.toDataURL("image/png").split(",")[1]!;
});
const bytes = Buffer.from(base64, "base64");
await writeFile(join(tmpdir(), "benchledger-project-render-fixture.png"), bytes);
return bytes;
}
test("a chosen project render survives reload and stays visible in gallery and compact list", async ({ page, request }, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 });
const project = await createProject(request, "Gallery chosen enclosure");
const cover = await uploadImage(request, project, await syntheticProductImage(page));
await signIn(page);
await page.getByRole("textbox", { name: "Find a project", exact: true }).fill(project.name);
const layout = page.getByRole("group", { name: "Project layout", exact: true });
await expect(layout.getByRole("button", { name: "Gallery", exact: true })).toHaveAttribute("aria-pressed", "true");
const card = projectCard(page, project.name);
await card.getByRole("button", { name: `Choose image for ${project.name}`, exact: true }).click();
await expect(page).toHaveURL(`${origin}/#/projects/${project.id}/files`);
const editor = await expectProjectImageEditor(page);
await expect(page.getByRole("button", { name: "Project image", exact: true })).toBeFocused();
await editor.getByRole("button", { name: "Choose project image", exact: true }).click();
await editor.getByRole("combobox", { name: "Project image file", exact: true }).selectOption(cover.id);
await editor.getByRole("combobox", { name: "Image represents", exact: true }).selectOption("render");
await editor.getByLabel("Image description", { exact: true }).fill("Synthetic enclosure design render");
await expectDecodedImage(editor.getByRole("img", { name: "Synthetic enclosure design render", exact: true }));
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
await page.screenshot({ path: join(tmpdir(), "benchledger-project-image-editor-phone.png"), fullPage: true });
await page.setViewportSize({ width: 1440, height: 1000 });
await editor.getByRole("button", { name: "Save project image", exact: true }).click();
await expect(editor).toContainText("Project image saved.");
await page.reload();
await openProjectImageEditor(page);
await expect(editor).toContainText("Design render · synthetic-enclosure-render.png");
await navigateWorkspace(page, "Projects");
await page.getByRole("textbox", { name: "Find a project", exact: true }).fill(project.name);
await expectDecodedImage(card.getByRole("img", { name: "Synthetic enclosure design render", exact: true }));
await expect(card).toContainText("Design render");
await page.screenshot({ path: join(tmpdir(), "benchledger-project-gallery-desktop.png"), fullPage: true });
expect((await new AxeBuilder({ page }).include(".home-projects").withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"]).analyze()).violations).toEqual([]);
await page.setViewportSize({ width: 1536, height: 1024 });
await page.getByRole("textbox", { name: "Find a project", exact: true }).fill("");
await expectDecodedImage(card.getByRole("img", { name: "Synthetic enclosure design render", exact: true }));
await expect(page.locator(".project-gallery .product-image-placeholder").filter({ hasText: "Add a project image" }).first()).toBeVisible();
await page.evaluate(async () => { await document.fonts.ready; window.scrollTo(0, 0); });
await expect(card).toBeInViewport();
await page.screenshot({ path: testInfo.outputPath("approved-projects-gallery-1536.png"), fullPage: false, animations: "disabled" });
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByRole("textbox", { name: "Find a project", exact: true }).fill(project.name);
await layout.getByRole("button", { name: "List", exact: true }).focus();
await page.keyboard.press("Space");
await expect(page.locator(".home-project-list")).toBeVisible();
await expectDecodedImage(card.getByRole("img", { name: "Synthetic enclosure design render", exact: true }));
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
await page.screenshot({ path: join(tmpdir(), "benchledger-project-list-phone.png"), fullPage: true });
await page.setViewportSize({ width: 1440, height: 1000 });
await page.reload();
await expect(layout.getByRole("button", { name: "List", exact: true })).toHaveAttribute("aria-pressed", "true");
await page.getByRole("textbox", { name: "Find a project", exact: true }).fill(project.name);
await expectDecodedImage(card.getByRole("img", { name: "Synthetic enclosure design render", exact: true }));
await layout.getByRole("button", { name: "Gallery", exact: true }).focus();
await page.keyboard.press("Enter");
await expect(page.locator(".project-gallery")).toBeVisible();
for (const width of [390, 320]) {
await page.setViewportSize({ width, height: 844 });
await expectDecodedImage(card.getByRole("img", { name: "Synthetic enclosure design render", exact: true }));
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
if (width === 390) await page.screenshot({ path: join(tmpdir(), "benchledger-project-gallery-phone.png"), fullPage: true });
}
await card.locator(".product-image-open").focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("heading", { name: project.name, exact: true })).toBeVisible();
await expect(page.getByRole("tab", { name: "Overview", exact: true })).toHaveAttribute("aria-selected", "true");
await page.setViewportSize({ width: 1536, height: 1024 });
await expectDecodedImage(page.locator(".project-overview").getByRole("img", { name: "Synthetic enclosure design render", exact: true }));
const nextAction = page.getByRole("region", { name: "Next project action", exact: true });
await expect(nextAction.getByRole("heading", { name: "Add what this build needs", exact: true })).toBeVisible();
await expect(nextAction.getByRole("button", { name: "Add first part", exact: true })).toBeVisible();
await page.evaluate(async () => { await document.fonts.ready; window.scrollTo(0, 0); });
await expect(page.locator(".project-overview-image .product-image")).toBeInViewport({ ratio: 1 });
await expect(nextAction).toBeInViewport({ ratio: 1 });
for (const name of [/^Build files/u, /^Build plan/u]) await expect(page.locator(".project-continue").getByRole("button", { name })).toBeInViewport({ ratio: 1 });
await expect(page.locator(".project-overview-image img")).toHaveCSS("object-fit", "contain");
await page.screenshot({ path: testInfo.outputPath("approved-project-overview-1536.png"), fullPage: false, animations: "disabled" });
});
test("project notes preserve long hashes and URLs without widening the phone overview", async ({ page, request }) => {
const hash = "abcdef0123456789".repeat(4);
const description = `Synthetic design notes.\nSHA-256 ${hash}\nhttps://example.invalid/designs/${hash}/assembly-reference`;
const project = await createProject(request, "Gallery detailed notes", description);
await signIn(page);
await page.getByRole("textbox", { name: "Find a project", exact: true }).fill(project.name);
await projectCard(page, project.name).getByRole("button", { name: `Open project ${project.name}`, exact: true }).click();
const notes = page.locator(".project-overview-notes > p");
await expect(notes).toHaveText(description);
for (const width of [390, 320, 1536]) {
await page.setViewportSize({ width, height: 844 });
await page.evaluate(() => document.fonts.ready);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
expect(await notes.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
await expect(notes).toHaveText(description);
}
});
test("a later project revision never borrows the previous revision's render", async ({ page, request }) => {
const project = await createProject(request, "Gallery revision enclosure");
const cover = await uploadImage(request, project, await syntheticProductImage(page), "previous-revision-render.png");
await mcp(request, "save_project_presentation", { projectId: project.id, projectRevisionId: project.revision, presentation: { expectedVersion: 0, coverArtifactId: cover.id, imageKind: "render", caption: "Earlier enclosure design" } });
await signIn(page);
await page.getByRole("textbox", { name: "Find a project", exact: true }).fill(project.name);
const card = projectCard(page, project.name);
await expectDecodedImage(card.getByRole("img", { name: "Earlier enclosure design", exact: true }));
await mcp(request, "create_project_revision", { projectId: project.id, summary: "New design without a render" });
await page.reload();
await page.getByRole("textbox", { name: "Find a project", exact: true }).fill(project.name);
await expect(card).toContainText("Add a project image");
await expect(card.getByRole("img")).toHaveCount(0);
await card.getByRole("button", { name: `Choose image for ${project.name}`, exact: true }).click();
const editor = await expectProjectImageEditor(page);
await expect(editor).toContainText("No image selected for this revision.");
await editor.getByRole("button", { name: "Choose project image", exact: true }).click();
await expect(editor.getByRole("combobox", { name: "Project image file" }).locator("option")).toHaveText(["No project image"]);
await expect(editor).toContainText("Images must belong to this revision or a current work item.");
});
test("a corrupt render response shows a useful fallback and can be retried", async ({ page, request }) => {
const project = await createProject(request, "Gallery retry enclosure");
const cover = await uploadImage(request, project, await syntheticProductImage(page));
await mcp(request, "save_project_presentation", { projectId: project.id, projectRevisionId: project.revision, presentation: { expectedVersion: 0, coverArtifactId: cover.id, imageKind: "reference", caption: "Synthetic enclosure reference" } });
const path = `**/artifacts/${cover.id}/download`;
await page.route(path, (route) => route.fulfill({ status: 200, contentType: "image/png", body: "Synthetic corrupt image response" }));
await signIn(page);
await page.getByRole("textbox", { name: "Find a project", exact: true }).fill(project.name);
const card = projectCard(page, project.name);
await expect(card).toContainText("Image unavailable");
await expect(card.getByRole("img")).toHaveCount(0);
await expect(card.getByRole("button", { name: `Open project ${project.name}`, exact: true })).toBeEnabled();
await page.unroute(path);
await card.getByRole("button", { name: "Retry image", exact: true }).click();
await expectDecodedImage(card.getByRole("img", { name: "Synthetic enclosure reference", exact: true }));
await expect(card).toContainText("Reference image");
});
test("new image uploads are selectable immediately and a rejected save preserves the draft", async ({ page, request }) => {
const project = await createProject(request, "Gallery uploaded enclosure");
const bytes = await syntheticProductImage(page);
await signIn(page);
await page.getByRole("textbox", { name: "Find a project", exact: true }).fill(project.name);
await projectCard(page, project.name).getByRole("button", { name: `Choose image for ${project.name}`, exact: true }).click();
const editor = await expectProjectImageEditor(page);
await editor.getByRole("button", { name: "Choose project image", exact: true }).click();
await expect(editor).toContainText("Upload a PNG, JPEG or WebP in Files first");
await editor.getByRole("button", { name: "Cancel image selection", exact: true }).click();
await page.getByLabel("Choose files to upload").setInputFiles({ name: "uploaded-project-image.png", mimeType: "image/png", buffer: bytes });
await page.getByRole("button", { name: "Add 1 file", exact: true }).click();
await expect(page.getByRole("button", { name: "Details for uploaded-project-image.png", exact: true })).toBeVisible();
await editor.getByRole("button", { name: "Choose project image", exact: true }).click();
await editor.getByRole("combobox", { name: "Project image file", exact: true }).selectOption({ label: "uploaded-project-image.png" });
await editor.getByRole("combobox", { name: "Image represents", exact: true }).selectOption("built_photo");
await editor.getByLabel("Image description", { exact: true }).fill("Synthetic uploaded enclosure image");
const path = `**/projects/${project.id}/revisions/${project.revision}/presentation`;
await page.route(path, async (route) => {
if (route.request().method() === "PUT") await route.fulfill({ status: 409, contentType: "application/json", json: { error: { code: "conflict", message: "The image selection changed. Review it before saving." } } });
else await route.continue();
});
await editor.getByRole("button", { name: "Save project image", exact: true }).click();
await expect(editor.getByRole("alert")).toContainText("The image selection changed");
await expect(editor.getByLabel("Image description", { exact: true })).toHaveValue("Synthetic uploaded enclosure image");
await expect(editor.getByRole("combobox", { name: "Image represents", exact: true })).toHaveValue("built_photo");
await expectDecodedImage(editor.getByRole("img", { name: "Synthetic uploaded enclosure image", exact: true }));
await page.unroute(path);
await editor.getByRole("button", { name: "Save project image", exact: true }).click();
await expect(editor).toContainText("Project image saved.");
await expect(editor).toContainText("Built-product photo · uploaded-project-image.png");
});