mirror of
https://github.com/willchen96/mike.git
synced 2026-07-26 23:51:08 +02:00
upstream-main (olp's tip) ships the #215/#216 liquid-surfaces UI, so the same
selector drifts fixed on the fork's suite (commit 3d46814 on main) also apply
here. Ports those realigned hunks, plus one upstream-specific route fix.
* Chat input placeholder is "How can I help?", not
"Ask a question about your documents..." (ChatInput/TRChatInput). Updated in
chat-management (rename/delete/project-assistant) and critical-path.
* The project-assistant empty state replaced the "+ Create New" text link with
a PillButton reading "Create" (ProjectAssistantTable) — critical-path and
chat-management now use getByRole("button", { name: "Create" }).
* The sidebar chat row's active marker is APP_SURFACE_ACTIVE_CLASS
("bg-app-surface-active"), not "bg-gray-200/60"; the row wrapper is now h-8,
not h-9 (SidebarChatItem). The rename/delete tests locate the row accordingly.
* The documents-toolbar folder button is "Folder" (TabPillButton wired to the
root createFolderAction in ProjectDocumentsView), not "Add Subfolder"; it
still opens the autofocused "Folder name" root input.
* NewTRModal's footer submit and the tabular page CTA both read "Create", so
the create-review helper scopes to the modal submit
(button[name="modalAction"][value="create-review"]).
* The built-in workflow detail test navigated to the flat /workflows/[id],
which upstream does not route — only the typed /workflows/assistant/[id] and
/workflows/tabular-review/[id] exist. builtin-cp-checklist is assistant-type,
so it now navigates to /workflows/assistant/builtin-cp-checklist (the path
the app itself links to via workflowDetailPath; works on the fork too).
Verified: full 27-test suite green against upstream-main + test-harness +
demo-mode(+provider-registry), backend + local Supabase (upstream schema.sql +
backend/migrations) + MinIO, demo model, e2e@mike.local.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC
320 lines
15 KiB
TypeScript
320 lines
15 KiB
TypeScript
/**
|
|
* 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 <h1>Tabular Reviews</h1>.
|
|
*
|
|
* DOM structure:
|
|
* <div class="… justify-between …">
|
|
* <h1>Tabular Reviews</h1>
|
|
* <div class="flex items-center gap-2"> ← xpath=../div[1] from h1
|
|
* <div>…<button>{SearchIcon}</button></div> ← HeaderSearchBtn
|
|
* <button>{PlusIcon}</button> ← new-review button (.last())
|
|
* </div>
|
|
* </div>
|
|
*
|
|
* 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<void>,
|
|
): Promise<string> {
|
|
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/<id>").
|
|
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 <main> 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
|
|
// <input type="file"> — 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 });
|
|
});
|