/** * AI Software Factory — E2E: Search index lag + click-through (TASK-0100). * * Proves the Phase-8 index-lag SLA against the REAL pipeline * (API event → worker stream consumer → OpenSearch → query-time permission * filter): * * 1. "index lag + click through" (spec req 4 + req 1): logged-in user * creates a page (UI) titled "Perf Probe {uuid}", body one sentence * with the same unique phrase; within 30 s the search box finds it * (assert the result row); clicking it lands on the page — the EXACT * page id in the URL — and the page title matches; delete the page → * within 30 s it is no longer returned (deletion-side lag). * 2. "private page of another user is never found" (spec Security * negative): a page whose source row has `isPrivate: true` (written * via the DB seam — no public API sets Page.isPrivate, recorded quirk) * must never appear in a SECOND member's search results, even after * the full 30 s window, while the CREATOR still finds it (a * search-broken environment fails the positive side, not the negative). * 3. "worker consumes a burst without backlog growth > 30 s" (spec * Backend Changes): publish page-created events through the public * API and assert the worker's `search-index:lag` metric (worker health * server, /health/search-index:lag) drains to 0 within 30 s. * * Serial on purpose: every test measures wall-clock lag; parallel specs * would steal CPU from the worker mid-window and flake the SLA assertions. * * Selector convention (tests/README.md): * data-testid="search-button" top-bar search button * data-testid="search-dialog" search overlay * data-testid="search-input" search query input * data-testid="search-result" a result row (asserted via text) * data-testid="page-title" page title * data-testid="page-context-menu" right-click menu on a page * data-testid="context-menu-trash" "Delete (move to trash)" */ import { test, expect } from "../fixtures"; import { addMemberViaSeam, createBlock, createPage, seamServiceToken } from "../utils/api"; import { assertNeverSearchable, getSearchIndexLag, measureCreateLagViaSearchUi, runSearchIndexBurst, setPagePrivateDirectDb, waitUntilUnsearchable, waitForSearchRow, SEARCH_LAG_TIMEOUT_MS } from "../utils/search-lag"; test.describe.configure({ mode: "serial" }); /** Real seed pages the burst's `page.updated` events cycle over (index path, not poison). */ const BURST_SEED_PAGES = 20; /** The burst size (spec Backend Changes: 1k-event burst). */ const BURST_EVENT_COUNT = 1_000; const uniqueToken = () => `Perf Probe ${crypto.randomUUID()}`; test.describe("Search — index lag + click through (TASK-0100)", () => { test("create page (UI) → searchable < 30 s → click lands on exact page → delete → unsearchable < 30 s", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Search Lag Space"); const token = uniqueToken(); const runId = Date.now(); // t0 is recorded INSIDE the create (before the page write is issued) — // spec req 1(a). measureCreateLagViaSearchUi owns the UI flow + the 2 s // polling and returns the measured lag (create-side SLA check). const t0 = Date.now(); let pageId = ""; const { lagMs: createLagMs } = await measureCreateLagViaSearchUi( authPage, ws.id, token, { onCreated: (id) => { pageId = id; }, timeoutMs: SEARCH_LAG_TIMEOUT_MS }, ); const createdAfterMs = Date.now() - t0; console.log( `[search-lag] create-side lag = ${createLagMs} ms (SLA < 30 000 ms; poll rounds ≈ ${Math.ceil( createLagMs / 2000, )})`, ); expect(createLagMs, `create-side index lag ${createLagMs} ms exceeded the 30 s SLA`).toBeLessThan( SEARCH_LAG_TIMEOUT_MS, ); expect(pageId, "the created page id must be known for the URL assertion").toBeTruthy(); void createdAfterMs; // ---- click through: the EXACT page id in the URL + title match -------- // Re-run the search deterministically (a poll round may have passed since // the result first appeared), then activate the row. const row = await reopenAndFindResult(authPage, token); await row.click(); // The web deep-links a page to /w/{slug}/page/{id} (deepLinkFor) — the page // id is the LAST path segment. (The spec's earlier `/p/` assert was // aspirational and did not match the live route.) await expect .poll(() => authPage.url(), { timeout: 10_000 }) .toContain(`/page/${pageId}`); // The title is a real INPUT with aria-label="Page title" (page-view.tsx). // An ``'s title lives in its `value` attribute, not its text // content — `toContainText` (text) would always see "". Use `toHaveValue`. await expect(authPage.getByRole("textbox", { name: "Page title" })).toHaveValue(token); // ---- deletion-side lag: trash the page → gone from search < 30 s ------- const t1 = Date.now(); // Trash via the API (deterministic): DELETE /pages/:id → pages.trash() // (soft delete: trashedAt on page + descendants, publishes page.deleted → // the worker removes the doc). The UI affordance is a hover-gated "More" // dropdown on the page-tree row (no testid), so the API is the stable // surface. (The old version tried getByTestId("page-context-menu") testids // that do not exist in the web, then read the access token from // localStorage — but S10 keeps the token in JS memory only, so it threw.) await api.delete(`/api/v1/pages/${pageId}`); // The trashed doc must leave the index — assert through the API surface // (the deterministic side), which is exactly what the search-lag util // measures. const { lagMs: deleteLagMs, polls } = await waitUntilUnsearchable(api, token, t1); console.log(`[search-lag] delete-side lag = ${deleteLagMs} ms (SLA < 30 000 ms) — ${polls} polls`); expect(deleteLagMs, `delete-side index lag ${deleteLagMs} ms exceeded the 30 s SLA`).toBeLessThan( SEARCH_LAG_TIMEOUT_MS, ); }); test("worker consumes a page-created burst without backlog growth > 30 s (search-index:lag)", async ({ api, freshWorkspace }) => { const ws = await freshWorkspace("Search Lag Burst Space"); // Sanity: the lag endpoint must be live for this assertion (worker booted // with the TASK-0100 build). const before = await getSearchIndexLag(); expect( before.available, "search-index:lag endpoint unavailable — the worker must expose /health/search-index:lag", ).toBe(true); // The burst is published DIRECTLY to the worker's input stream (factory:events) // as real `page.updated` envelopes cycling over BURST_SEED_PAGES real pages — // each one makes the worker run a genuine re-index (DB read + OpenSearch upsert), // so this is real indexing load, not a no-op. (Publishing through the public API // is infeasible: the 100/min per-user rate limit makes a 1k-event burst impossible // via HTTP, and the stream is exactly what `search-index:lag` observes.) const seedPageIds: string[] = []; for (let i = 0; i < BURST_SEED_PAGES; i += 1) { const p = await createPage(api, ws.id, { title: `Burst seed ${i}` }); seedPageIds.push(p.id); } const burst = await runSearchIndexBurst({ pageIds: seedPageIds, workspaceId: ws.id, count: BURST_EVENT_COUNT, }); console.log( `[search-lag] burst of ${burst.published} page.updated events drained in ${burst.drainMs} ms; ` + `backlog samples (tMs:backlog) = ${burst.samples.map((s) => `${s.tMs}:${s.backlog}`).join(" ")}`, ); // runSearchIndexBurst already throws if the backlog is non-zero at the // 30 s deadline — reaching here IS the assertion (drain < 30 s). }); test("private page of another user is never found (permission negative; creator still finds it)", async ({ api, authPage, secondUser, freshWorkspace }) => { const ws = await freshWorkspace("Search Lag Private Space"); const token = uniqueToken(); // The secondUser fixture is a LAZY factory: `const second = await secondUser()` // registers + logs in a fresh user and returns { user, page, api }. const second = await secondUser(); // Bring the second user into the workspace through the internal test // seam (CI e2e job sets INTERNAL_SERVICE_TOKEN + INTERNAL_TEST_SEAM=1). await addMemberViaSeam(seamServiceToken(), ws.id, second.user.email, "MEMBER"); // Creator (demo user) creates a page + one body sentence… // TASK-0100 S5: the API block-create contract (POST /pages/:id/blocks, // CreateBlockDto.type = z.enum(BLOCK_TYPES)) uses the UPPERCASE Prisma // block types ("PARAGRAPH"), not the web client's lowercase union — so // send the API's canonical value (a lowercase "paragraph" 400'd with // "Invalid enum value … received 'paragraph'"). const page = await createPage(api, ws.id, { title: token }); await createBlock(api, page.id, { type: "PARAGRAPH", content: { text: `The ${token} phrase lives in this private document body.` } }); // …then the page goes PRIVATE at the source row (no public API sets // Page.isPrivate — recorded quirk; the worker stamps the grant snapshot // from the row, so the DB write is what makes the doc creator-only). const beforeWrite = await new Promise((r) => setTimeout(r, 1500)); // let the first (public) upsert land void beforeWrite; await setPagePrivateDirectDb(page.id, true); // Touch the page so the worker RE-STAMPS the grant snapshot from the row // (isPrivate → workspace:false, users:[creator]). await api.patch(`/api/v1/pages/${page.id}`, { data: { description: "private probe" } }); // The CREATOR must find it (search works end-to-end; proves the negative // below is a permission outcome, not a broken index). await authPage.goto(`/w/${ws.id}`); await expect(authPage.getByTestId("search-button")).toBeVisible({ timeout: 15_000 }); const creatorRow = authPage.getByTestId("search-result").filter({ hasText: token }); const creatorResult = await expect .poll(async () => { // Close first so `openDialog` is a genuine re-open: the dialog resets // its input to "" on open, which is what makes the next `fill()` re- // trigger the 225 ms debounce + re-query. (Leaving it open and re-filling // the SAME value would not re-query — the input did not change.) await authPage.keyboard.press("Escape"); await openDialog(authPage); await authPage.getByTestId("search-input").fill(token); // Debounce-aware: wait for the row to render (debounce + fetch + paint) // instead of a one-shot count() that always saw the pre-debounce state. return (await waitForSearchRow(creatorRow)) ? 1 : 0; }, { timeout: SEARCH_LAG_TIMEOUT_MS, intervals: [2000] }) .toBeGreaterThanOrEqual(1); void creatorResult; await authPage.keyboard.press("Escape"); // The SECOND user must NEVER find it — poll the full 30 s window. The // negative side asserts against a REAL search as the second user (their own // Bearer `api` context) — the deterministic surface. (The old version passed // a fake `uiSearchContext` object, so `assertNeverSearchable`'s // `ctx.post` never executed and the negative was VACUOUS: a search that // wrongly leaked the private page would not have failed the test.) await assertNeverSearchable( second.api, token, { description: "private-page permission negative", durationMs: SEARCH_LAG_TIMEOUT_MS } ); }); }); // --------------------------------------------------------------------------- // UI helpers (dialog is the live search surface; the API util is the // cross-check in the search-lag utils) // --------------------------------------------------------------------------- async function openDialog(page: import("@playwright/test").Page): Promise { // Idempotent: if the dialog is ALREADY open, leave it open. A poll that // re-runs `openDialog` without having closed the previous iteration would // otherwise click the search button, which the open dialog's backdrop // (z-[85]) intercepts → the click times out. (TASK-0100 S5: the // creator-side poll left the dialog open on a debounced/empty round.) const dialog = page.getByTestId("search-dialog"); if (await dialog.isVisible().catch(() => false)) return; await page.getByTestId("search-button").click(); await expect(dialog).toBeVisible(); } /** Re-open the dialog and wait for a result row containing `text`. */ async function reopenAndFindResult( page: import("@playwright/test").Page, text: string, ): Promise { const row = page.getByTestId("search-result").filter({ hasText: text }); // The dialog re-queries on every poll; keep trying until a row is visible // AND clickable (a query refresh may briefly clear the list). await expect .poll(async () => { // Close first so `openDialog` is a genuine re-open: the dialog resets its // input to "" on open, re-triggering the debounce + query on the fill. // (With the idempotent openDialog, a no-op re-open + same-value fill would // not re-query and every round would see the stale/empty list.) await page.keyboard.press("Escape"); await openDialog(page); await page.getByTestId("search-input").fill(text); // Bounded wait: the dialog debounces the fill (225 ms) + fetches, so a // one-shot isVisible() always saw the pre-debounce (empty) state. return waitForSearchRow(row); }, { timeout: SEARCH_LAG_TIMEOUT_MS, intervals: [2000] }) .toBeTruthy(); return row.first(); }