e2e/project-removal.spec.ts
Rama main · réplica de lectura del repositorio original.
import { clickProjectAction, showProjectAction, navigateWorkspace } from "./workspace-controls";
import { expect, test, type Page } from "@playwright/test";
const project = {
id: "project-removal-e2e",
name: "Retained Archive E2E",
description: "A project kept for removal-flow coverage.",
status: "archived",
currentRevisionId: "revision-removal-e2e",
updatedAt: "2026-09-01T10:00:00.000Z",
createdAt: "2026-09-01T09:00:00.000Z",
version: 4,
workItems: [{ id: "work-removal-e2e", projectId: "project-removal-e2e", name: "Removal fixture", kind: "project", description: "Retained history fixture." }],
currentRevision: { id: "revision-removal-e2e", projectId: "project-removal-e2e", number: 1, name: "Initial", status: "concept", createdAt: "2026-09-01T09:00:00.000Z", version: 1, bom: [], artifacts: [] }
};
async function mockRemovalWorkspace(page: Page): Promise<{ readonly deleteRequest: () => { readonly body: unknown; readonly ifMatch: string | undefined; readonly idempotencyKey: string | undefined } | undefined }> {
let deleted: { readonly body: unknown; readonly ifMatch: string | undefined; readonly idempotencyKey: string | undefined } | undefined;
await page.route("**/api/v1/auth/access", async (route) => {
if (route.request().method() !== "GET") return route.continue();
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ mode: "lan_open", passwordConfigured: false, version: 1 }) });
});
await page.route("**/api/v1/auth/lan-session", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ authenticated: true, actor: "e2e", csrfToken: "csrf-removal-e2e", expiresAt: "2026-09-01T18:00:00.000Z" }) });
});
await page.route("**/api/v1/auth/session", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ authenticated: true, actor: "e2e", source: "ui", scopes: ["read", "write"] }) });
});
await page.route("**/api/v1/health", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "ok", service: "benchledger", version: "e2e", demo: false, now: "2026-09-01T10:00:00.000Z" }) });
});
await page.route("**/api/v1/workspace", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "api", fetchedAt: "2026-09-01T10:00:00.000Z", inventory: [], projects: [], offers: [] }) });
});
await page.route("**/api/v1/projects**", async (route) => {
if (route.request().method() === "DELETE" && new URL(route.request().url()).pathname.endsWith("/projects/project-removal-e2e")) {
deleted = { body: route.request().postDataJSON(), ifMatch: route.request().headers()["if-match"], idempotencyKey: route.request().headers()["idempotency-key"] };
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: { id: project.id, name: project.name, removedAt: "2026-09-01T10:01:00.000Z", removedBy: "e2e", lastLifecycleStatus: "archived", releasedReservationIds: ["reservation-e2e"], version: 5, auditId: "audit-removal-e2e" }, audit: { id: "audit-removal-e2e" }, correlationId: "correlation-removal-e2e", replayed: false }) });
return;
}
if (route.request().method() !== "GET") return route.continue();
const requestUrl = new URL(route.request().url());
const archived = requestUrl.searchParams.get("status") === "archived" && !deleted;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: archived ? [project] : [], limit: 200, total: archived ? 1 : 0 }) });
});
await page.route("**/api/v1/inventory/categories*", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: [], limit: 200, total: 0 }) });
});
await page.route("**/api/v1/project-library?**", route => route.fulfill({ json: { data: new URL(route.request().url()).searchParams.get("status") === "archived" && !deleted ? [project] : [] } }));
return { deleteRequest: () => deleted };
}
async function mockRestoreWorkspace(page: Page): Promise<{ readonly restoreRequests: () => number }> {
let restoreRequests = 0;
await page.route("**/api/v1/auth/access", async (route) => {
if (route.request().method() !== "GET") return route.continue();
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ mode: "lan_open", passwordConfigured: false, version: 1 }) });
});
await page.route("**/api/v1/auth/lan-session", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ authenticated: true, actor: "e2e", csrfToken: "csrf-restore-e2e", expiresAt: "2026-09-01T18:00:00.000Z" }) });
});
await page.route("**/api/v1/auth/session", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ authenticated: true, actor: "e2e", source: "ui", scopes: ["read", "write"] }) });
});
await page.route("**/api/v1/health", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "ok", service: "benchledger", version: "e2e", demo: false, now: "2026-09-01T10:00:00.000Z" }) });
});
await page.route("**/api/v1/workspace", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "api", fetchedAt: "2026-09-01T10:00:00.000Z", inventory: [], projects: [], offers: [] }) });
});
await page.route("**/api/v1/projects**", async (route) => {
if (route.request().method() === "POST" && new URL(route.request().url()).pathname.endsWith("/projects/project-removal-e2e/restore")) {
restoreRequests += 1;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: { ...project, status: "idea", version: 5, updatedAt: "2026-09-01T10:01:00.000Z" }, audit: { id: "audit-restore-e2e" }, correlationId: "correlation-restore-e2e", replayed: false }) });
return;
}
if (route.request().method() !== "GET") return route.continue();
const requestUrl = new URL(route.request().url());
const archived = requestUrl.searchParams.get("status") === "archived";
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: archived ? [project] : [], limit: 200, total: archived ? 1 : 0 }) });
});
await page.route("**/api/v1/inventory/categories*", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: [], limit: 200, total: 0 }) });
});
await page.route("**/api/v1/project-library?**", route => route.fulfill({ json: { data: new URL(route.request().url()).searchParams.get("status") === "archived" ? (restoreRequests ? [] : [project]) : (restoreRequests ? [{ ...project, status: "idea", version: 5 }] : []) } }));
return { restoreRequests: () => restoreRequests };
}
test.describe("restore confirmation", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("restores an archived project only after confirmation", async ({ page }) => {
const harness = await mockRestoreWorkspace(page);
await page.goto("/");
await navigateWorkspace(page, "Projects");
await expect(page.getByRole("heading", { name: "Projects", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Archived", exact: true }).click();
await page.getByRole("button", { name: "Open project Retained Archive E2E", exact: true }).click();
await expect(page.getByRole("heading", { name: "Retained Archive E2E", exact: true })).toBeVisible();
await expect(page.locator("#main-content")).not.toContainText(/reservation|tombstone|audit/iu);
const restoreButton = page.getByRole("button", { name: "Restore project", exact: true });
await restoreButton.click();
expect(harness.restoreRequests()).toBe(0);
const dialog = page.getByRole("alertdialog", { name: "Restore Retained Archive E2E?" });
await expect(dialog).toContainText("This moves the project to Idea. Stock released when it was archived will not be set aside again.");
await expect(dialog).not.toContainText(/reservation|tombstone|audit/iu);
await expect(dialog.getByRole("button", { name: "Close dialog" })).toBeFocused();
await expect(dialog.getByRole("button", { name: "Cancel", exact: true })).toBeVisible();
await expect(dialog.getByRole("button", { name: "Restore project", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(restoreButton).toBeFocused();
expect(harness.restoreRequests()).toBe(0);
await restoreButton.click();
await page.getByRole("alertdialog", { name: "Restore Retained Archive E2E?" }).getByRole("button", { name: "Restore project", exact: true }).click();
expect(harness.restoreRequests()).toBe(1);
await expect(page.getByText("Retained Archive E2E was restored to Idea. Previously released stock was not set aside again.", { exact: true })).toBeVisible();
await expect(await showProjectAction(page, "Archive project")).toBeVisible();
});
});
test("deletes an archived project after a single confirmation and hides it from Archived", async ({ page }) => {
const harness = await mockRemovalWorkspace(page);
await page.goto("/");
await navigateWorkspace(page, "Projects");
await expect(page.getByRole("heading", { name: "Projects", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Archived", exact: true }).click();
await page.getByRole("button", { name: "Open project Retained Archive E2E", exact: true }).click();
await expect(page.getByRole("heading", { name: "Retained Archive E2E", exact: true })).toBeVisible();
await clickProjectAction(page, "Delete project");
await page.getByRole("button", { name: "Delete project permanently", exact: true }).click();
let dialog = page.getByRole("alertdialog", { name: "Delete Retained Archive E2E?" });
await expect(dialog).toContainText("This action is irreversible.");
await expect(dialog).not.toContainText(/reservation|tombstone|audit/iu);
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
expect(harness.deleteRequest()).toBeUndefined();
await clickProjectAction(page, "Delete project");
await page.getByRole("button", { name: "Delete project permanently", exact: true }).click();
dialog = page.getByRole("alertdialog", { name: "Delete Retained Archive E2E?" });
const removeButton = dialog.getByRole("button", { name: "Delete project", exact: true });
await expect(removeButton).toBeEnabled();
await removeButton.click();
expect(harness.deleteRequest()).toMatchObject({ body: { name: "Retained Archive E2E" }, ifMatch: "4" });
expect(harness.deleteRequest()?.idempotencyKey).toBeTruthy();
await expect(page.getByRole("heading", { name: "Projects", exact: true })).toBeVisible();
await expect.poll(() => new URL(page.url()).hash).toBe("#/");
await expect(page.getByRole("region", { name: "Getting started", exact: true })).toBeVisible();
await expect(page.getByRole("region", { name: "Project navigator", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "View archived projects", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Open project Retained Archive E2E", exact: true })).toHaveCount(0);
await navigateWorkspace(page, "Settings");
await page.goBack();
await expect(page.getByRole("heading", { name: "Projects", exact: true })).toBeVisible();
await expect(page.getByRole("region", { name: "Getting started", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Open project Retained Archive E2E", exact: true })).toHaveCount(0);
await page.goForward();
await expect.poll(() => new URL(page.url()).hash).toBe("#/settings");
await expect(page.getByRole("region", { name: "Project navigator", exact: true })).toHaveCount(0);
});
test("restores the active or archived project named by browser history", async ({ page }) => {
const activeProject = {
...project,
id: "project-active-e2e",
name: "Active Project E2E",
status: "idea",
currentRevisionId: "revision-active-e2e",
workItems: [],
currentRevision: {
...project.currentRevision,
id: "revision-active-e2e",
projectId: "project-active-e2e",
bom: [],
artifacts: [],
},
};
await page.route("**/api/v1/auth/access", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ mode: "lan_open", passwordConfigured: false, version: 1 }) });
});
await page.route("**/api/v1/auth/lan-session", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ authenticated: true, actor: "e2e", csrfToken: "csrf-history-e2e", expiresAt: "2026-09-01T18:00:00.000Z" }) });
});
await page.route("**/api/v1/auth/session", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ authenticated: true, actor: "e2e", source: "ui", scopes: ["read", "write"] }) });
});
await page.route("**/api/v1/health", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "ok", service: "benchledger", version: "e2e", demo: false, now: "2026-09-01T10:00:00.000Z" }) });
});
await page.route("**/api/v1/workspace", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "api", fetchedAt: "2026-09-01T10:00:00.000Z", inventory: [], projects: [activeProject], offers: [] }) });
});
await page.route("**/api/v1/projects**", async (route) => {
const archived = new URL(route.request().url()).searchParams.get("status") === "archived";
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: archived ? [project] : [activeProject], limit: 200, total: 1 }) });
});
await page.route("**/api/v1/inventory/categories*", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: [], limit: 200, total: 0 }) });
});
await page.route("**/api/v1/project-library?**", route => route.fulfill({ json: { data: new URL(route.request().url()).searchParams.get("status") === "archived" ? [project] : [activeProject] } }));
await page.goto(`/#/projects/${project.id}/files`);
await expect(page.getByRole("heading", { name: project.name, exact: true })).toBeVisible();
await navigateWorkspace(page, "Projects");
await page.getByRole("button", { name: "Active", exact: true }).click();
await page.getByRole("button", { name: `Open project ${activeProject.name}`, exact: true }).click();
await expect(page.getByRole("heading", { name: activeProject.name, exact: true })).toBeVisible();
await navigateWorkspace(page, "Projects");
await page.getByRole("button", { name: "Archived", exact: true }).click();
await page.getByRole("button", { name: `Open project ${project.name}`, exact: true }).click();
await expect(page.getByRole("heading", { name: project.name, exact: true })).toBeVisible();
await page.goBack();
await expect(page.getByRole("heading", { name: "Projects", exact: true })).toBeVisible();
await page.goBack();
await expect(page.getByRole("heading", { name: activeProject.name, exact: true })).toBeVisible();
await expect(page.locator(".archive-notice")).toHaveCount(0);
await page.goBack();
await expect(page.getByRole("heading", { name: "Projects", exact: true })).toBeVisible();
await page.goBack();
await expect(page.getByRole("heading", { name: project.name, exact: true })).toBeVisible();
await expect(page.locator(".archive-notice")).toBeVisible();
await expect(page.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true");
});