1/** 2 * AI Software Factory — E2E: Search index lag + click-through (TASK-0100). 3 * 4 * Proves the Phase-8 index-lag SLA against the REAL pipeline 5 * (API event → worker stream consumer → OpenSearch → query-time permission 6 * filter): 7 * 8 * 1. "index lag + click through" (spec req 4 + req 1): logged-in user 9 * creates a page (UI) titled "Perf Probe {uuid}", body one sentence 10 * with the same unique phrase; within 30 s the search box finds it 11 * (assert the result row); clicking it lands on the page — the EXACT 12 * page id in the URL — and the page title matches; delete the page → 13 * within 30 s it is no longer returned (deletion-side lag). 14 * 2. "private page of another user is never found" (spec Security 15 * negative): a page whose source row has `isPrivate: true` (written 16 * via the DB seam — no public API sets Page.isPrivate, recorded quirk) 17 * must never appear in a SECOND member's search results, even after 18 * the full 30 s window, while the CREATOR still finds it (a 19 * search-broken environment fails the positive side, not the negative). 20 * 3. "worker consumes a burst without backlog growth > 30 s" (spec 21 * Backend Changes): publish page-created events through the public 22 * API and assert the worker's `search-index:lag` metric (worker health 23 * server, /health/search-index:lag) drains to 0 within 30 s. 24 * 25 * Serial on purpose: every test measures wall-clock lag; parallel specs 26 * would steal CPU from the worker mid-window and flake the SLA assertions. 27 * 28 * Selector convention (tests/README.md): 29 * data-testid="search-button" top-bar search button 30 * data-testid="search-dialog" search overlay 31 * data-testid="search-input" search query input 32 * data-testid="search-result" a result row (asserted via text) 33 * data-testid="page-title" page title 34 * data-testid="page-context-menu" right-click menu on a page 35 * data-testid="context-menu-trash" "Delete (move to trash)" 36 */ 37import { test, expect } from "../fixtures"; 38import { 39 addMemberViaSeam, 40 createBlock, 41 createPage, 42 seamServiceToken 43} from "../utils/api"; 44import { 45 assertNeverSearchable, 46 getSearchIndexLag, 47 measureCreateLagViaSearchUi, 48 runSearchIndexBurst, 49 setPagePrivateDirectDb, 50 waitUntilUnsearchable, 51 waitForSearchRow, 52 SEARCH_LAG_TIMEOUT_MS 53} from "../utils/search-lag"; 54 55test.describe.configure({ mode: "serial" }); 56 57/** Real seed pages the burst's `page.updated` events cycle over (index path, not poison). */ 58const BURST_SEED_PAGES = 20; 59/** The burst size (spec Backend Changes: 1k-event burst). */ 60const BURST_EVENT_COUNT = 1_000; 61 62const uniqueToken = () => `Perf Probe ${crypto.randomUUID()}`; 63 64test.describe("Search — index lag + click through (TASK-0100)", () => { 65 test("create page (UI) → searchable < 30 s → click lands on exact page → delete → unsearchable < 30 s", async ({ 66 authPage, 67 api, 68 freshWorkspace 69 }) => { 70 const ws = await freshWorkspace("Search Lag Space"); 71 const token = uniqueToken(); 72 const runId = Date.now(); 73 74 // t0 is recorded INSIDE the create (before the page write is issued) — 75 // spec req 1(a). measureCreateLagViaSearchUi owns the UI flow + the 2 s 76 // polling and returns the measured lag (create-side SLA check). 77 const t0 = Date.now(); 78 let pageId = ""; 79 const { lagMs: createLagMs } = await measureCreateLagViaSearchUi( 80 authPage, 81 ws.id, 82 token, 83 { 84 onCreated: (id) => { 85 pageId = id; 86 }, 87 timeoutMs: SEARCH_LAG_TIMEOUT_MS 88 }, 89 ); 90 const createdAfterMs = Date.now() - t0; 91 console.log( 92 `[search-lag] create-side lag = ${createLagMs} ms (SLA < 30 000 ms; poll rounds ≈ ${Math.ceil( 93 createLagMs / 2000, 94 )})`, 95 ); 96 expect(createLagMs, `create-side index lag ${createLagMs} ms exceeded the 30 s SLA`).toBeLessThan( 97 SEARCH_LAG_TIMEOUT_MS, 98 ); 99 expect(pageId, "the created page id must be known for the URL assertion").toBeTruthy(); 100 void createdAfterMs; 101 102 // ---- click through: the EXACT page id in the URL + title match -------- 103 // Re-run the search deterministically (a poll round may have passed since 104 // the result first appeared), then activate the row. 105 const row = await reopenAndFindResult(authPage, token); 106 await row.click(); 107 // The web deep-links a page to /w/{slug}/page/{id} (deepLinkFor) — the page 108 // id is the LAST path segment. (The spec's earlier `/p/` assert was 109 // aspirational and did not match the live route.) 110 await expect 111 .poll(() => authPage.url(), { timeout: 10_000 }) 112 .toContain(`/page/${pageId}`); 113 // The title is a real INPUT with aria-label="Page title" (page-view.tsx). 114 // An `<input>`'s title lives in its `value` attribute, not its text 115 // content — `toContainText` (text) would always see "". Use `toHaveValue`. 116 await expect(authPage.getByRole("textbox", { name: "Page title" })).toHaveValue(token); 117 118 // ---- deletion-side lag: trash the page → gone from search < 30 s ------- 119 const t1 = Date.now(); 120 // Trash via the API (deterministic): DELETE /pages/:id → pages.trash() 121 // (soft delete: trashedAt on page + descendants, publishes page.deleted → 122 // the worker removes the doc). The UI affordance is a hover-gated "More" 123 // dropdown on the page-tree row (no testid), so the API is the stable 124 // surface. (The old version tried getByTestId("page-context-menu") testids 125 // that do not exist in the web, then read the access token from 126 // localStorage — but S10 keeps the token in JS memory only, so it threw.) 127 await api.delete(`/api/v1/pages/${pageId}`); 128 129 // The trashed doc must leave the index — assert through the API surface 130 // (the deterministic side), which is exactly what the search-lag util 131 // measures. 132 const { lagMs: deleteLagMs, polls } = await waitUntilUnsearchable(api, token, t1); 133 console.log(`[search-lag] delete-side lag = ${deleteLagMs} ms (SLA < 30 000 ms) — ${polls} polls`); 134 expect(deleteLagMs, `delete-side index lag ${deleteLagMs} ms exceeded the 30 s SLA`).toBeLessThan( 135 SEARCH_LAG_TIMEOUT_MS, 136 ); 137 }); 138 139 test("worker consumes a page-created burst without backlog growth > 30 s (search-index:lag)", async ({ 140 api, 141 freshWorkspace 142 }) => { 143 const ws = await freshWorkspace("Search Lag Burst Space"); 144 145 // Sanity: the lag endpoint must be live for this assertion (worker booted 146 // with the TASK-0100 build). 147 const before = await getSearchIndexLag(); 148 expect( 149 before.available, 150 "search-index:lag endpoint unavailable — the worker must expose /health/search-index:lag", 151 ).toBe(true); 152 153 // The burst is published DIRECTLY to the worker's input stream (factory:events) 154 // as real `page.updated` envelopes cycling over BURST_SEED_PAGES real pages — 155 // each one makes the worker run a genuine re-index (DB read + OpenSearch upsert), 156 // so this is real indexing load, not a no-op. (Publishing through the public API 157 // is infeasible: the 100/min per-user rate limit makes a 1k-event burst impossible 158 // via HTTP, and the stream is exactly what `search-index:lag` observes.) 159 const seedPageIds: string[] = []; 160 for (let i = 0; i < BURST_SEED_PAGES; i += 1) { 161 const p = await createPage(api, ws.id, { title: `Burst seed ${i}` }); 162 seedPageIds.push(p.id); 163 } 164 165 const burst = await runSearchIndexBurst({ 166 pageIds: seedPageIds, 167 workspaceId: ws.id, 168 count: BURST_EVENT_COUNT, 169 }); 170 console.log( 171 `[search-lag] burst of ${burst.published} page.updated events drained in ${burst.drainMs} ms; ` + 172 `backlog samples (tMs:backlog) = ${burst.samples.map((s) => `${s.tMs}:${s.backlog}`).join(" ")}`, 173 ); 174 // runSearchIndexBurst already throws if the backlog is non-zero at the 175 // 30 s deadline — reaching here IS the assertion (drain < 30 s). 176 }); 177 178 test("private page of another user is never found (permission negative; creator still finds it)", async ({ 179 api, 180 authPage, 181 secondUser, 182 freshWorkspace 183 }) => { 184 const ws = await freshWorkspace("Search Lag Private Space"); 185 const token = uniqueToken(); 186 187 // The secondUser fixture is a LAZY factory: `const second = await secondUser()` 188 // registers + logs in a fresh user and returns { user, page, api }. 189 const second = await secondUser(); 190 191 // Bring the second user into the workspace through the internal test 192 // seam (CI e2e job sets INTERNAL_SERVICE_TOKEN + INTERNAL_TEST_SEAM=1). 193 await addMemberViaSeam(seamServiceToken(), ws.id, second.user.email, "MEMBER"); 194 195 // Creator (demo user) creates a page + one body sentence… 196 // TASK-0100 S5: the API block-create contract (POST /pages/:id/blocks, 197 // CreateBlockDto.type = z.enum(BLOCK_TYPES)) uses the UPPERCASE Prisma 198 // block types ("PARAGRAPH"), not the web client's lowercase union — so 199 // send the API's canonical value (a lowercase "paragraph" 400'd with 200 // "Invalid enum value … received 'paragraph'"). 201 const page = await createPage(api, ws.id, { title: token }); 202 await createBlock(api, page.id, { 203 type: "PARAGRAPH", 204 content: { text: `The ${token} phrase lives in this private document body.` } 205 }); 206 207 // …then the page goes PRIVATE at the source row (no public API sets 208 // Page.isPrivate — recorded quirk; the worker stamps the grant snapshot 209 // from the row, so the DB write is what makes the doc creator-only). 210 const beforeWrite = await new Promise((r) => setTimeout(r, 1500)); // let the first (public) upsert land 211 void beforeWrite; 212 await setPagePrivateDirectDb(page.id, true); 213 // Touch the page so the worker RE-STAMPS the grant snapshot from the row 214 // (isPrivate → workspace:false, users:[creator]). 215 await api.patch(`/api/v1/pages/${page.id}`, { data: { description: "private probe" } }); 216 217 // The CREATOR must find it (search works end-to-end; proves the negative 218 // below is a permission outcome, not a broken index). 219 await authPage.goto(`/w/${ws.id}`); 220 await expect(authPage.getByTestId("search-button")).toBeVisible({ timeout: 15_000 }); 221 const creatorRow = authPage.getByTestId("search-result").filter({ hasText: token }); 222 const creatorResult = await expect 223 .poll(async () => { 224 // Close first so `openDialog` is a genuine re-open: the dialog resets 225 // its input to "" on open, which is what makes the next `fill()` re- 226 // trigger the 225 ms debounce + re-query. (Leaving it open and re-filling 227 // the SAME value would not re-query — the input did not change.) 228 await authPage.keyboard.press("Escape"); 229 await openDialog(authPage); 230 await authPage.getByTestId("search-input").fill(token); 231 // Debounce-aware: wait for the row to render (debounce + fetch + paint) 232 // instead of a one-shot count() that always saw the pre-debounce state. 233 return (await waitForSearchRow(creatorRow)) ? 1 : 0; 234 }, { timeout: SEARCH_LAG_TIMEOUT_MS, intervals: [2000] }) 235 .toBeGreaterThanOrEqual(1); 236 void creatorResult; 237 await authPage.keyboard.press("Escape"); 238 239 // The SECOND user must NEVER find it — poll the full 30 s window. The 240 // negative side asserts against a REAL search as the second user (their own 241 // Bearer `api` context) — the deterministic surface. (The old version passed 242 // a fake `uiSearchContext` object, so `assertNeverSearchable`'s 243 // `ctx.post` never executed and the negative was VACUOUS: a search that 244 // wrongly leaked the private page would not have failed the test.) 245 await assertNeverSearchable( 246 second.api, 247 token, 248 { 249 description: "private-page permission negative", 250 durationMs: SEARCH_LAG_TIMEOUT_MS 251 } 252 ); 253 }); 254}); 255 256// --------------------------------------------------------------------------- 257// UI helpers (dialog is the live search surface; the API util is the 258// cross-check in the search-lag utils) 259// --------------------------------------------------------------------------- 260 261async function openDialog(page: import("@playwright/test").Page): Promise<void> { 262 // Idempotent: if the dialog is ALREADY open, leave it open. A poll that 263 // re-runs `openDialog` without having closed the previous iteration would 264 // otherwise click the search button, which the open dialog's backdrop 265 // (z-[85]) intercepts → the click times out. (TASK-0100 S5: the 266 // creator-side poll left the dialog open on a debounced/empty round.) 267 const dialog = page.getByTestId("search-dialog"); 268 if (await dialog.isVisible().catch(() => false)) return; 269 await page.getByTestId("search-button").click(); 270 await expect(dialog).toBeVisible(); 271} 272 273/** Re-open the dialog and wait for a result row containing `text`. */ 274async function reopenAndFindResult( 275 page: import("@playwright/test").Page, 276 text: string, 277): Promise<import("@playwright/test").Locator> { 278 const row = page.getByTestId("search-result").filter({ hasText: text }); 279 // The dialog re-queries on every poll; keep trying until a row is visible 280 // AND clickable (a query refresh may briefly clear the list). 281 await expect 282 .poll(async () => { 283 // Close first so `openDialog` is a genuine re-open: the dialog resets its 284 // input to "" on open, re-triggering the debounce + query on the fill. 285 // (With the idempotent openDialog, a no-op re-open + same-value fill would 286 // not re-query and every round would see the stale/empty list.) 287 await page.keyboard.press("Escape"); 288 await openDialog(page); 289 await page.getByTestId("search-input").fill(text); 290 // Bounded wait: the dialog debounces the fill (225 ms) + fetches, so a 291 // one-shot isVisible() always saw the pre-debounce (empty) state. 292 return waitForSearchRow(row); 293 }, { timeout: SEARCH_LAG_TIMEOUT_MS, intervals: [2000] }) 294 .toBeTruthy(); 295 return row.first(); 296} 297