/**
* Tabular Review E2E tests:
* 1. Navigate to /tabular-reviews — the list page loads correctly
* 2. Create a new tabular review — modal flow, API call, redirect to detail
* 3. Review detail page — table structure and toolbar controls render
* 4. Add a document — upload via AddDocumentsModal, row appears in table
*
* Prerequisite: auth.setup.ts has already saved the session to e2e/.auth/user.json
* Test user: e2e@mike.local / E2eTestPass1! (storageState inherited from playwright.config.ts)
*/
import { test, expect } from "@playwright/test";
import path from "path";
const PDF_FIXTURE = path.join(__dirname, "fixtures/test.pdf");
// Run these tests sequentially in a single worker. The global config sets
// fullyParallel:true, which would otherwise run the create/detail tests
// concurrently — and they each trigger the first on-demand `next dev` compile
// of the dynamic /tabular-reviews/[id] route at the same time. Under that
// compiler contention the dev server drops in-flight client navigations, so
// router.push() lands but the URL never changes. Serial mode lets the first
// test warm (compile) the route, after which the rest navigate near-instantly.
test.describe.configure({ mode: "serial" });
/* ─── Helpers ────────────────────────────────────────────────────────────── */
/**
* Click the "New review" Plus icon button in the Tabular Reviews list-page header.
*
* The button has no aria-label or visible text — it is an icon-only button
* rendered immediately after the HeaderSearchBtn (magnifier icon) in the same
* flex container that is the sibling of the
*
* TODO: once aria-label="New review" is added to that button, replace with:
* page.getByRole("button", { name: "New review" })
*/
async function clickNewReviewBtn(page: import("@playwright/test").Page) {
// Walk from the h1 to the parent div, then select its first div child
// (the actions container); the last button within it is the Plus icon.
const actionsDiv = page
.getByRole("heading", { name: "Tabular Reviews" })
.locator("xpath=../div[1]"); // TODO: verify selector
await actionsDiv.getByRole("button").last().click();
}
/** Predicate matching the create-review request: POST /tabular-review (exact). */
const isCreateReviewPost = (r: import("@playwright/test").Response) =>
/\/tabular-review\/?$/.test(new URL(r.url()).pathname) &&
r.request().method() === "POST";
/**
* Open the AddNewTRModal (assumes /tabular-reviews is already loaded) and return
* its "Review name" input once visible.
*/
async function openNewReviewModal(page: import("@playwright/test").Page) {
await clickNewReviewBtn(page);
const titleInput = page.getByPlaceholder("Review name");
await expect(titleInput).toBeVisible({ timeout: 10_000 });
return titleInput;
}
/**
* Create a tabular review through the real modal flow and land on its detail
* page. Returns the title that was entered so callers can assert on it.
*
* Two dev-environment flakes are tolerated here so the *behaviour under test*
* (create → detail page) is what's exercised, not infrastructure noise:
*
* 1. The local Supabase gateway (Kong) intermittently returns 500
* "An invalid response was received from the upstream server" / "fetch
* failed" when its PostgREST upstream is momentarily unavailable — observed
* on ~30% of create calls, and far more often while the modal's per-project
* fetch burst is hammering Supabase concurrently. We wait for that burst to
* settle before submitting, and retry the POST on transient 5xx. A genuinely
* broken create flow fails *every* attempt, so regressions are still caught.
* 2. `next dev` compiles the dynamic /tabular-reviews/[id] route on first
* navigation (15-30s cold); under that latency the dev server can drop the
* in-flight client navigation, so we fall back to an explicit goto.
*
* @param onFirstOpen optional assertion run against the modal on the first open
* (used by Test 2 to verify the workflow-template default renders).
*/
async function createReview(
page: import("@playwright/test").Page,
label = "E2E Review",
onFirstOpen?: () => Promise,
): Promise {
await page.goto("/tabular-reviews");
await expect(
page.getByRole("heading", { name: "Tabular Reviews" }),
).toBeVisible({ timeout: 10_000 });
const reviewName = `${label} ${Date.now()}`;
let review: { id: string } | null = null;
for (let attempt = 0; attempt < 10 && !review; attempt++) {
if (attempt > 0) {
// The modal closes itself on submit; reopen for the retry.
await page.goto("/tabular-reviews");
await expect(
page.getByRole("heading", { name: "Tabular Reviews" }),
).toBeVisible({ timeout: 10_000 });
}
const titleInput = await openNewReviewModal(page);
if (attempt === 0 && onFirstOpen) await onFirstOpen();
await titleInput.fill(reviewName);
// NewTRModal is a two-step wizard ("Details" → "Add Documents"); the
// "Create" submit button only exists on the second step, and "Next" only
// enables once the review has a name.
await page.getByRole("button", { name: "Next", exact: true }).click();
// Let the modal's project-fetch burst settle so the create POST doesn't
// compete with it on the flaky local Supabase (best-effort; the HMR
// socket means networkidle may not fully settle, so it's time-boxed).
await page
.waitForLoadState("networkidle", { timeout: 8_000 })
.catch(() => {});
// exact: true hits the modal's submit button only — the list page's
// empty-state "+ Create New" CTA and the "Create under a project" toggle
// also contain the word "Create".
const respP = page
.waitForResponse(isCreateReviewPost, { timeout: 30_000 })
.catch(() => null);
// The modal footer's submit button and the page's own "Create" CTA both
// read "Create"; scope to the modal's submit (button[name="modalAction"]
// value="create-review") to avoid a strict-mode ambiguity.
await page
.locator('button[name="modalAction"][value="create-review"]')
.click();
const resp = await respP;
if (resp && resp.ok()) {
review = (await resp.json()) as { id: string };
}
// else: transient upstream 5xx — loop reopens the modal and retries.
}
expect(
review,
"POST /tabular-review never returned 2xx after retries — create flow broken?",
).not.toBeNull();
// createTabularReview() → router.push("/tabular-reviews/").
await page
.waitForURL(`**/tabular-reviews/${review!.id}`, { timeout: 60_000 })
.catch(() => page.goto(`/tabular-reviews/${review!.id}`));
await expect(page).toHaveURL(
new RegExp(`/tabular-reviews/${review!.id}`),
{ timeout: 60_000 },
);
return reviewName;
}
/* ─── Test 1: list page loads ─────────────────────────────────────────────── */
test("navigates to /tabular-reviews and the list page renders", async ({
page,
}) => {
// REGRESSION: fails if the /tabular-reviews route is removed or broken
await page.goto("/tabular-reviews");
await expect(page).toHaveURL(/\/tabular-reviews/);
// The page renders an h1 heading with the section title
await expect(
page.getByRole("heading", { name: "Tabular Reviews" }),
).toBeVisible({ timeout: 10_000 });
// The ToolbarTabs bar renders the "All" tab
// TODO: verify selector if ToolbarTabs uses role="tab" instead of role="button"
await expect(page.getByText("All")).toBeVisible({ timeout: 5_000 });
});
/* ─── Test 2: create a new tabular review ─────────────────────────────────── */
test("creates a new tabular review and is redirected to the detail page", async ({
page,
}) => {
// Headroom for the create-POST retries (flaky local Supabase) plus the
// on-demand `next dev` compile of the dynamic /tabular-reviews/[id] route.
test.setTimeout(180_000);
// REGRESSION: fails if createTabularReview() API call is removed or the
// /tabular-reviews POST route is broken (every retry attempt then fails,
// so `review` stays null and createReview's not-null assertion trips).
//
// createReview opens the modal, verifies the workflow-template default
// renders, submits, and lands on the new review's detail page.
const reviewName = await createReview(page, "E2E Review", async () => {
// The workflow template control defaults to "No template - start from
// scratch" once the templates request resolves (it shows "Loading
// templates…" until then), so allow time for that listWorkflows() fetch.
// NewTRModal renders it as a button, with a hyphen — not an em dash.
await expect(
page.getByRole("button", { name: "No template - start from scratch" }),
).toBeVisible({ timeout: 15_000 });
});
// The new review's title appears in the page breadcrumb header
await expect(page.getByText(reviewName)).toBeVisible({ timeout: 10_000 });
});
/* ─── Test 3: review detail page table structure ─────────────────────────── */
test("review detail page renders the table structure and toolbar controls", async ({
page,
}) => {
// Headroom for create-POST retries (flaky local Supabase) plus the
// on-demand `next dev` compile of the dynamic detail route.
test.setTimeout(180_000);
// REGRESSION: fails if the /tabular-reviews/[id] route, TRView, or TRTable
// component is broken
const reviewName = await createReview(page, "E2E Table Review");
// The breadcrumb header shows the review title via RenameableTitle
await expect(page.getByText(reviewName)).toBeVisible({ timeout: 10_000 });
// The breadcrumb also contains a "Tabular Reviews" back-nav button. Scope to
// the landmark: the left sidebar nav also has a "Tabular Reviews"
// button, so an unscoped role query is a strict-mode violation. exact:true
// avoids also matching the mobile-only "Back to Tabular Reviews" control.
await expect(
page
.getByRole("main")
.getByRole("button", { name: "Tabular Reviews", exact: true }),
).toBeVisible({ timeout: 5_000 });
// TRTable always renders a "Document" column header, even when the review
// is empty. This is visible in both the empty-state and populated states.
await expect(
page.getByText("Document", { exact: true }),
).toBeVisible({ timeout: 10_000 });
// The toolbar renders "Add Columns" and "Add Documents" once loading is done.
// Both may also appear in TRTable's empty-state CTA, so .first() is used.
await expect(
page.getByRole("button", { name: /Add Columns/ }).first(),
).toBeVisible({ timeout: 5_000 });
await expect(
page.getByRole("button", { name: /Add Documents/ }).first(),
).toBeVisible({ timeout: 5_000 });
});
/* ─── Test 4: add a document to a review ─────────────────────────────────── */
test("adds a document to a tabular review and the row appears in the table", async ({
page,
}) => {
// Headroom for create-POST retries, the detail-route compile, and the
// upload + document-link round-trips.
test.setTimeout(180_000);
// REGRESSION: fails if the document-to-review linking
// (PATCH /tabular-reviews/:id with document_ids) or the upload endpoint breaks
const reviewName = await createReview(page, "E2E Doc Review");
// reviewName is already confirmed visible on the detail page by createReview
// Add the document and assert the row appears. The upload endpoint and the
// document-link PATCH both go through the same flaky local Supabase that
// intermittently 500s (see createReview), so the whole open→upload→confirm
// round-trip is retried until the row renders. The behaviour under test —
// a successful upload + link surfacing the row — is unchanged; a genuine
// break in the upload or link path fails *every* attempt (the modal already
// re-deletes nothing, so each retry uploads a fresh copy).
const row = page.getByText("test.pdf").first();
const confirmBtn = page.getByRole("button", { name: "Confirm" });
for (let attempt = 0; attempt < 6; attempt++) {
// Open AddDocumentsModal (standalone path → AddDocumentsModal, not
// AddProjectDocsModal). first() handles both toolbar & empty-state CTA.
const addDocsBtn = page
.getByRole("button", { name: /Add Documents/ })
.first();
await expect(addDocsBtn).toBeVisible({ timeout: 10_000 });
await addDocsBtn.click();
// The footer's "Upload" button programmatically clicks a hidden
// — Playwright intercepts it as a file-chooser event.
const uploadBtn = page.getByRole("button", { name: "Upload" });
await expect(uploadBtn).toBeVisible({ timeout: 5_000 });
const fileChooserPromise = page.waitForEvent("filechooser");
await uploadBtn.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(PDF_FIXTURE);
// After a successful upload the server document is auto-selected and the
// "Confirm" button transitions disabled → enabled. A 5xx upload leaves it
// disabled — close the modal and retry.
const becameEnabled = await expect(confirmBtn)
.toBeEnabled({ timeout: 20_000 })
.then(() => true)
.catch(() => false);
if (!becameEnabled) {
await page.getByRole("button", { name: "Cancel" }).click();
continue; // upload 5xx'd — retry
}
// Confirm → onSelect() → handleAddDocuments() → updateTabularReview()
// PATCH → setDocuments() → TRTable renders the new row with doc.filename.
await confirmBtn.click();
const appeared = await row
.waitFor({ state: "visible", timeout: 15_000 })
.then(() => true)
.catch(() => false);
if (appeared) break;
// The link PATCH may have 5xx'd (modal already closed) — loop and retry.
}
await expect(row).toBeVisible({ timeout: 15_000 });
});