mike/e2e/project-management.spec.ts
QA Runner af38a45067 test(e2e): drop transient-5xx retry scaffolding now that the fetch storm is fixed
With the directory fetch storm eliminated (previous commit: batched
listProjects?include=documents instead of a getProject burst per modal
open), the local Supabase gateway no longer collapses under modal-open
load, so the specs' 5xx-oriented scaffolding is removed:

- create-with-retry loops (project create x2, chat create x2, workflow
  create, tabular-review create x10-attempt, review add-document x6)
- reload-guards for transient "Project not found" (waitForProjectLoaded,
  gotoProjectRow, project-assistant tab loop)
- networkidle settle-waits whose purpose was to let the per-project fetch
  burst drain before submitting (NewProjectModal / NewTRModal)
- oversized per-test timeouts justified by the storm (180s -> 60-120s)

Kept, deliberately:
- the title-generation settle logic in chat rename/delete
- chat submit's model-seeding retry loop (guards a documented
  useSelectedModel localStorage hydration race, unrelated to the gateway)
- account display-name toPass block (guards a documented profile
  hydration race, unrelated to the gateway)
- generic short networkidle settles after login/logout navigation
- Playwright's own retries: 2 on CI

Verified: with the fix, 3 consecutive full-suite runs (plus 2 more before
de-retry) were green with zero Playwright retries, running against an
account seeded with ~40 projects — a larger directory fan-out than CI's
fresh database ever produces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:44:00 -07:00

342 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Project management E2E tests:
* 1. Rename a project inline
* 2. Delete a project
* 3. Create a folder inside a project
* 4. File upload type validation (wrong type rejected)
*
* Prerequisite: auth.setup.ts has already saved the session to e2e/.auth/user.json.
* All tests run with the authenticated storageState configured in playwright.config.ts —
* no test.use override is needed here.
*
* Each test creates its own uniquely-named project so tests are fully isolated.
*/
import { test, expect } from "@playwright/test";
import path from "path";
const PDF_FIXTURE = path.join(__dirname, "fixtures/test.pdf");
// ─── Shared helper ────────────────────────────────────────────────────────────
/**
* Creates a new project via the "New project" modal and waits until
* NewProjectModal's onCreated handler redirects to /projects/<id>.
*
* Pass `filePath` to also upload a document during creation. This matters for
* the folder test: ProjectPage only renders the document tree (and therefore
* the root "Add Subfolder" input) when the project is NOT empty — an empty
* project shows the "Drop PDF or DOCX files here" placeholder instead, which
* has no folder input.
*/
async function createProject(
page: import("@playwright/test").Page,
projectName: string,
filePath?: string,
) {
/* Creation is a navigation + modal wizard + (optionally) a file upload; the
per-test `{ timeout }` option passed to test() is silently ignored by
Playwright (that object only accepts tag/annotation), so raise the budget
here, where the slow work happens, for every caller. */
test.setTimeout(60_000);
await page.goto("/projects");
await expect(page).toHaveURL(/\/projects/, { timeout: 10_000 });
/* The Plus icon button in the header has aria-label="New project" */
const createBtn = page.getByRole("button", { name: "New project" });
await expect(createBtn).toBeVisible({ timeout: 10_000 });
await createBtn.click();
const nameInput = page.getByPlaceholder("Project name");
await expect(nameInput).toBeVisible({ timeout: 5_000 });
await nameInput.fill(projectName);
/* NewProjectModal is a two-step wizard: "Details" (name / CM number /
practice / colleagues) then "Add Documents". Only the second step has a
submit button — the first step's primary action is a plain "Next". */
await page.getByRole("button", { name: "Next", exact: true }).click();
if (filePath) {
/* On the documents step the footer "Upload" button opens a hidden file
input, and its label gains a "(n)" count once files are attached. */
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByRole("button", { name: /^Upload/ }).click();
(await fileChooserPromise).setFiles(filePath);
await expect(
page.getByRole("button", { name: /^Upload \(1\)/ }),
).toBeVisible({ timeout: 5_000 });
}
/* Submit — NewProjectModal's onCreated calls router.push(`/projects/${id}`).
The PDF upload runs (awaited) inside handleSubmit before onCreated fires,
so allow extra time for navigation when a file is attached.
(The modal's FileDirectory used to fan out a getProject() request per
existing project on open, which could overwhelm the local Supabase
gateway and required settle-waits plus a submit-retry loop here. The
directory now loads via one batched listProjects?include=documents
request, so a single submit is reliable.)
The documents step's primary action submits the form (its label flips
to "Creating…" while in flight, so match on the submit type instead). */
const navTimeout = filePath ? 30_000 : 15_000;
await page.locator('button[type="submit"]').click();
await page.waitForURL(/\/projects\/.+/, { timeout: navTimeout });
}
/**
* Navigate to the projects list and return the table row for `projectName`.
* Rows are <div class="group">; the sidebar "Recent Projects" renders the same
* name as a <button>, so scoping to div.group avoids a strict-mode double match.
*/
async function gotoProjectRow(
page: import("@playwright/test").Page,
projectName: string,
) {
const row = page.locator("div.group").filter({ hasText: projectName });
await page.goto("/projects");
await expect(row.first()).toBeVisible({ timeout: 12_000 });
return row;
}
/**
* After creating a project we land on /projects/<id>, whose ProjectPage fetches
* getProject() once on mount. Wait until `anchor` — a control that only renders
* once the project has loaded — becomes visible.
*/
async function waitForProjectLoaded(
page: import("@playwright/test").Page,
anchor: import("@playwright/test").Locator,
) {
await expect(anchor).toBeVisible({ timeout: 15_000 });
}
// ─── Test 1: Rename a project inline ─────────────────────────────────────────
test("rename a project via Edit details", async ({ page }) => {
const projectName = `E2E Proj ${Date.now()}`;
await createProject(page, projectName);
/* Navigate to the projects list (where the rename UI lives) and grab the
row. Each project row is a <div class="group">. The sidebar "Recent
Projects" list renders the same name as a <button> (not div.group), so
scoping to div.group keeps the lookup to the table row. */
const row = await gotoProjectRow(page, projectName);
/* The ··· button (middle-dot U+00B7 × 3) is inside the last cell of the row */
const ellipsisBtn = row.locator("button").filter({ hasText: "···" });
await ellipsisBtn.click();
/*
* The row menu (RowActions) offers "Edit details" and "Delete" — the old
* inline "Rename" affordance is gone; renaming now happens in
* ProjectDetailsModal, which also carries the CM number and practice fields.
*/
await page.getByRole("button", { name: "Edit details", exact: true }).click();
/* ProjectDetailsModal's name field is pre-filled with the current name;
fill() clears it first, platform-independently. */
const newName = `E2E Proj Renamed ${Date.now()}`;
const renameInput = page.locator("#project-details-name");
await expect(renameInput).toBeVisible({ timeout: 10_000 });
await renameInput.fill(newName);
// REGRESSION: fails if ProjectDetailsModal's onSave (updateProject) is removed
await page.getByRole("button", { name: "Update", exact: true }).click();
/* handleRenameSubmit optimistically updates the projects list state.
Scope to table rows (div.group) so the sidebar's stale copy of the old
name does not interfere with the negative assertion below. */
// REGRESSION: fails if rename input or submit handler is removed
await expect(
page.locator("div.group").filter({ hasText: newName }),
).toBeVisible({ timeout: 5_000 });
await expect(
page.locator("div.group").filter({ hasText: projectName }),
).toHaveCount(0);
});
// ─── Test 2: Delete a project ─────────────────────────────────────────────────
test("delete a project", async ({ page }) => {
const projectName = `E2E Proj ${Date.now()}`;
await createProject(page, projectName);
/*
* Back to the projects list.
* Rows are scoped to div.group — the sidebar "Recent Projects" list also
* shows the name as a <button>, so an unscoped getByText would match two
* elements.
*
* The row checkbox is wrapped in a div with onClick={e.stopPropagation()}
* to prevent accidental row navigation. Clicking the checkbox alone is safe.
*/
const row = await gotoProjectRow(page, projectName);
const checkbox = row.locator('input[type="checkbox"]');
await checkbox.click();
/*
* The "Actions" button is conditionally rendered only when selectedIds.length > 0.
* It opens a small dropdown containing a "Delete" option.
*/
const actionsBtn = page.getByRole("button", { name: /^Actions/ });
await expect(actionsBtn).toBeVisible({ timeout: 3_000 });
await actionsBtn.click();
/* exact:true so the substring match can't pick up any other button whose
accessible name merely contains "Delete". */
const deleteBtn = page.getByRole("button", { name: "Delete", exact: true });
await expect(deleteBtn).toBeVisible({ timeout: 3_000 });
// REGRESSION: fails if `handleDeleteSelected` is removed
await deleteBtn.click();
/* handleDeleteSelected removes the project from local state immediately.
Scope to table rows so a stale sidebar entry can't keep this truthy. */
await expect(
page.locator("div.group").filter({ hasText: projectName }),
).toHaveCount(0, { timeout: 10_000 });
});
// ─── Test 3: Create a folder inside a project ────────────────────────────────
test("create a folder inside a project", async ({ page }) => {
const projectName = `E2E Proj ${Date.now()}`;
/* Create WITH a document so the project isn't empty. The "Add Subfolder"
button always sits in the documents toolbar, but the root folder INPUT
only renders inside the document tree (ProjectPage.renderLevel), which is
shown only for a non-empty project — an empty project shows the "Drop PDF
or DOCX files here" placeholder instead, with no folder input. */
await createProject(page, projectName, PDF_FIXTURE);
/*
* After createProject we are on the new project page (Documents tab). The
* documents toolbar (which hosts the folder-create button) only appears
* once the project has loaded.
*/
/* The olp UI renamed the documents-toolbar folder-create button from
"Add Subfolder" to "Folder" (a TabPillButton wired to the root
createFolderAction — ProjectDocumentsView). Clicking it still renders the
autofocused "Folder name" input at root level (creatingIn === null). */
const addSubfolderBtn = page.getByRole("button", { name: "Folder" });
await waitForProjectLoaded(page, addSubfolderBtn);
/* Confirm the uploaded document rendered, i.e. the project is non-empty and
the document tree (and therefore the root folder input) will render. */
await expect(page.getByText("test.pdf").first()).toBeVisible({
timeout: 10_000,
});
/* Clicking "Add Subfolder" sets creatingFolderIn = null (root level). */
await addSubfolderBtn.click();
/*
* renderFolderInput renders an <input> with placeholder "Folder name"
* that is autoFocused. Pressing Enter calls handleCreateFolder(null)
* which creates a folder at the root level.
*/
const folderInput = page.getByPlaceholder("Folder name");
await expect(folderInput).toBeVisible({ timeout: 3_000 });
const folderName = `Test Folder ${Date.now()}`;
await folderInput.fill(folderName);
await folderInput.press("Enter");
/*
* handleCreateFolder optimistically inserts the folder into local state,
* then replaces the temp entry with the real folder from the API.
*/
// REGRESSION: fails if folder creation button or API call is removed
await expect(page.getByText(folderName)).toBeVisible({ timeout: 10_000 });
});
// ─── Test 4: File upload type validation (wrong type rejected) ────────────────
test("file upload type validation — .txt file is rejected", async ({ page }) => {
const projectName = `E2E Proj ${Date.now()}`;
await createProject(page, projectName);
/*
* After createProject we are on the project's Documents tab.
* The "Add Documents" button opens AddDocumentsModal which has a
* hidden file input with accept=".pdf,.docx,.doc".
*
* Validation is now two layers, and this test covers both:
* (a) UI: AddDocumentsModal filters unsupported files client-side
* (partitionSupportedDocumentFiles) and shows a visible warning —
* no request is sent, so we assert the warning + absence of the file.
* (b) Server: the upload endpoint must still 400 unsupported extensions
* (defense in depth for API/SDK callers that bypass the web UI).
* The UI never emits that request anymore, so we exercise the
* endpoint directly with the browser session's bearer token.
*/
/* Open the Add Documents modal. The "Add Documents" button only renders
once ProjectPage has loaded the project. */
const addDocsBtn = page.getByRole("button", { name: "Add Documents" });
await waitForProjectLoaded(page, addDocsBtn);
/* (b) Server-side rejection — REGRESSION: fails if type validation is
removed from the upload handler. */
const projectId = page.url().match(/\/projects\/([0-9a-f-]{36})/)?.[1];
expect(projectId, "expected to be on a /projects/<id> page").toBeTruthy();
const accessToken = await page.evaluate(() => {
const item = Object.entries(localStorage).find(([k]) =>
k.includes("auth-token"),
);
if (!item) return null;
try {
return JSON.parse(item[1]).access_token ?? null;
} catch {
return null;
}
});
expect(accessToken, "expected a Supabase session in localStorage").toBeTruthy();
const apiBase = process.env.MIKE_API_BASE_URL ?? "http://localhost:3001";
const uploadResponse = await page.request.post(
`${apiBase}/projects/${projectId}/documents`,
{
headers: { Authorization: `Bearer ${accessToken}` },
multipart: {
file: {
name: "test.txt",
mimeType: "text/plain",
buffer: Buffer.from(
"This is a plain text file that should be rejected.",
),
},
},
},
);
expect(uploadResponse.status()).toBe(400);
/* (a) UI-side filtering with a visible warning. */
await addDocsBtn.click();
const fileChooserPromise = page.waitForEvent("filechooser");
/* The Upload button label is "Upload" (not "Uploading…") when idle */
await page.getByRole("button", { name: "Upload" }).first().click();
const fileChooser = await fileChooserPromise;
/*
* Playwright's setFiles accepts an in-memory file descriptor, bypassing
* the browser's accept-attribute filter so the client-side partition
* logic (not the accept attribute) is what's under test.
*/
await fileChooser.setFiles({
name: "test.txt",
mimeType: "text/plain",
buffer: Buffer.from("This is a plain text file that should be rejected."),
});
// REGRESSION: fails if the visible unsupported-type warning is removed
// (UNSUPPORTED_DOCUMENT_WARNING_MESSAGE in documentUploadValidation.ts).
await expect(
page.getByText(
"Unsupported file type. Only PDF, Word, Excel, and PowerPoint files can be uploaded.",
),
).toBeVisible({ timeout: 10_000 });
/* The .txt file must not appear in the modal's document list */
await expect(page.getByText("test.txt")).not.toBeVisible();
});