/**
* 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();
}