1
26import { PrismaClient } from "@prisma/client";
27import { expect, type APIRequestContext, type Locator, type Page } from "@playwright/test";
28
29
30export const SEARCH_LAG_TIMEOUT_MS = 30_000;
31
32export const SEARCH_POLL_INTERVAL_MS = 2_000;
33
41export const SEARCH_ROW_RENDER_WINDOW_MS = 2_500;
42
43
49export async function waitForSearchRow(row: Locator, windowMs: number = SEARCH_ROW_RENDER_WINDOW_MS): Promise<boolean> {
50 try {
51 await expect(row.first()).toBeVisible({ timeout: windowMs });
52 return true;
53 } catch {
54 return false;
55 }
56}
57
58
63const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
64
65
66export const WORKER_HEALTH_URL = process.env.WORKER_HEALTH_URL ?? "http://localhost:4003";
67
68export interface LagResult {
69
70 lagMs: number;
71
72 polls: number;
73
74 tookMs?: number;
75}
76
77interface SearchEnvelope {
78 data?: { id?: string; docId?: string; url?: string; title?: string; snippet?: string }[];
79 nextCursor?: string | null;
80 tookMs?: number;
81}
82
83export interface SearchResponse {
84 status: number;
85 rows: SearchEnvelope["data"];
86 tookMs?: number;
87
88 unavailable?: boolean;
89}
90
91
103export async function search(ctx: APIRequestContext, q: string, limit = 10): Promise<SearchResponse> {
104 const res = await ctx.post("/api/v1/search", { data: { q, limit } });
105 if (res.status() !== 200) {
106 const text = await res.text();
107 let code: unknown;
108 try {
109 code = (JSON.parse(text) as { error?: { code?: unknown } }).error?.code;
110 } catch {
111 code = undefined;
112 }
113 if (code === "SEARCH_UNAVAILABLE") return { status: res.status(), rows: [], tookMs: 0, unavailable: true };
114 throw new Error(`search: POST /api/v1/search "${q}" → ${res.status}: ${text.slice(0, 300)}`);
115 }
116 const body = (await res.json()) as SearchEnvelope;
117 return { status: 200, rows: body.data ?? [], tookMs: body.tookMs };
118}
119
120
125function rowsContain(rows: SearchResponse["rows"], q: string): boolean {
126 return (rows ?? []).some((r) => JSON.stringify(r).includes(q));
127}
128
129
133export async function waitUntilSearchable(
134 ctx: APIRequestContext,
135 q: string,
136 t0: number,
137 opts: { timeoutMs?: number; intervalMs?: number } = {},
138): Promise<LagResult> {
139 const timeoutMs = opts.timeoutMs ?? SEARCH_LAG_TIMEOUT_MS;
140 const intervalMs = opts.intervalMs ?? SEARCH_POLL_INTERVAL_MS;
141 const deadline = Date.now() + timeoutMs;
142 let polls = 0;
143 let lastStatus = 0;
144
145 while (true) {
146 polls += 1;
147 const { status, rows, tookMs } = await search(ctx, q).catch(() => ({
148 status: 0,
149 rows: [] as SearchResponse["rows"],
150 tookMs: 0,
151 unavailable: true,
152 }));
153 lastStatus = status;
154 if (rowsContain(rows, q)) {
155 return { lagMs: Date.now() - t0, polls, tookMs };
156 }
157 if (Date.now() >= deadline) {
158 throw new Error(
159 `index lag (create): "${q}" not searchable within ${timeoutMs} ms after ${polls} polls ` +
160 `(last search status ${lastStatus})`,
161 );
162 }
163 await sleep(intervalMs);
164 }
165}
166
167
172export async function waitUntilUnsearchable(
173 ctx: APIRequestContext,
174 q: string,
175 t0: number,
176 opts: { timeoutMs?: number; intervalMs?: number } = {},
177): Promise<LagResult> {
178 const timeoutMs = opts.timeoutMs ?? SEARCH_LAG_TIMEOUT_MS;
179 const intervalMs = opts.intervalMs ?? SEARCH_POLL_INTERVAL_MS;
180 const deadline = Date.now() + timeoutMs;
181 let polls = 0;
182
183 while (true) {
184 polls += 1;
185 const { rows } = await search(ctx, q).catch(() => ({ rows: [] as SearchResponse["rows"] }));
186 if (!rowsContain(rows, q)) {
187 return { lagMs: Date.now() - t0, polls };
188 }
189 if (Date.now() >= deadline) {
190 throw new Error(
191 `index lag (delete): "${q}" still searchable ${timeoutMs} ms after deletion ` +
192 `(the worker must remove the doc on page.deleted)`,
193 );
194 }
195 await sleep(intervalMs);
196 }
197}
198
199
205export async function assertNeverSearchable(
206 ctx: APIRequestContext,
207 q: string,
208 opts: { durationMs?: number; intervalMs?: number; description?: string } = {},
209): Promise<{ polls: number }> {
210 const durationMs = opts.durationMs ?? SEARCH_LAG_TIMEOUT_MS;
211 const intervalMs = opts.intervalMs ?? SEARCH_POLL_INTERVAL_MS;
212 const deadline = Date.now() + durationMs;
213 let polls = 0;
214
215 while (true) {
216 polls += 1;
217 const { rows } = await search(ctx, q).catch(() => ({ rows: [] as SearchResponse["rows"] }));
218 if (rowsContain(rows, q)) {
219 throw new Error(
220 `${opts.description ?? "permission negative"}: "${q}" WAS visible after ${polls} poll(s) — ` +
221 `it must never be found`,
222 );
223 }
224 if (Date.now() >= deadline) break;
225 await sleep(intervalMs);
226 }
227 return { polls };
228}
229
230
231
232
233
234export interface CreateLagViaUiOptions {
235
236 onCreated?: (pageId: string) => void;
237
238 timeoutMs?: number;
239}
240
241
260export async function measureCreateLagViaSearchUi(
261 page: Page,
262 workspaceId: string,
263 token: string,
264 opts: CreateLagViaUiOptions = {},
265): Promise<LagResult> {
266 const timeoutMs = opts.timeoutMs ?? SEARCH_LAG_TIMEOUT_MS;
267 const intervalMs = SEARCH_POLL_INTERVAL_MS;
268
269 await page.goto(`/w/${workspaceId}`);
270 const newPageBtn = page.getByRole("button", { name: "New page" });
271 await expect(newPageBtn, 'the sidebar "New page" button').toBeVisible({ timeout: 15_000 });
272
273 const t0 = Date.now();
274 await newPageBtn.click();
275
276
277 const title = page.getByRole("textbox", { name: "Page title" });
278 await expect(title, 'the "Page title" input').toBeVisible({ timeout: 15_000 });
279 await title.click();
280 await title.fill(token);
281 await title.press("Tab");
282
283
284 const pageId = new URL(page.url()).pathname.split("/").filter(Boolean).pop() ?? "";
285 if (pageId) opts.onCreated?.(pageId);
286
287 const deadline = t0 + timeoutMs;
288 let polls = 0;
289 const dialog = page.getByTestId("search-dialog");
290 const input = page.getByTestId("search-input");
291 const row = page.getByTestId("search-result").filter({ hasText: token });
292
293 for (;;) {
294 polls += 1;
295 await page.getByTestId("search-button").click();
296 await expect(dialog).toBeVisible();
297 await input.fill(token);
298
299
300 const visible = await waitForSearchRow(row);
301 if (visible) {
302 await page.keyboard.press("Escape");
303 return { lagMs: Date.now() - t0, polls };
304 }
305 await page.keyboard.press("Escape");
306 if (Date.now() >= deadline) {
307 throw new Error(
308 `measureCreateLagViaSearchUi: "${token}" not found in the search UI within ` +
309 `${timeoutMs} ms after ${polls} polls`,
310 );
311 }
312 await sleep(intervalMs);
313 }
314}
315
316
317
318
319
320export interface SearchIndexLag {
321 metric: "search-index:lag";
322
323 available: boolean;
324
325 pending: number;
326
327 lagEntries: number;
328
329 backlog: number;
330}
331
332
337export async function getSearchIndexLag(): Promise<SearchIndexLag> {
338 const fallback: SearchIndexLag = {
339 metric: "search-index:lag",
340 available: false,
341 pending: 0,
342 lagEntries: 0,
343 backlog: 0
344 };
345 try {
346 const res = await fetch(`${WORKER_HEALTH_URL}/health/search-index:lag`, {
347 signal: AbortSignal.timeout(5_000)
348 });
349 if (res.status !== 200) return fallback;
350 const body = (await res.json()) as Partial<SearchIndexLag>;
351 if (typeof body.available !== "boolean") return fallback;
352 return {
353 metric: "search-index:lag",
354 available: body.available,
355 pending: body.pending ?? 0,
356 lagEntries: body.lagEntries ?? 0,
357 backlog: body.backlog ?? 0
358 };
359 } catch {
360 return fallback;
361 }
362}
363
364export interface BurstResult {
365
366 published: number;
367
368 burstMs: number;
369
370 drainMs: number;
371
372 samples: { tMs: number; backlog: number }[];
373}
374
375
395export async function runSearchIndexBurst(opts: {
396
397 redisUrl?: string;
398
399 pageIds: string[];
400 workspaceId: string;
401
402 count?: number;
403 timeoutMs?: number;
404 intervalMs?: number;
405}): Promise<BurstResult> {
406 const { redisUrl, pageIds, workspaceId } = opts;
407 const count = opts.count ?? 1_000;
408 const timeoutMs = opts.timeoutMs ?? SEARCH_LAG_TIMEOUT_MS;
409 const intervalMs = opts.intervalMs ?? SEARCH_POLL_INTERVAL_MS;
410 if (pageIds.length === 0) throw new Error("runSearchIndexBurst: pageIds must be non-empty");
411
412
413
414 const { default: Redis } = await import("ioredis");
415 const redis = new Redis(redisUrl ?? process.env.REDIS_URL ?? "redis://localhost:6379", {
416 maxRetriesPerRequest: 2,
417 lazyConnect: true
418 });
419
420 const t0 = Date.now();
421 let burstMs = 0;
422 try {
423 await redis.connect().catch(() => {
424 throw new Error(
425 `runSearchIndexBurst: cannot connect to Redis (${redisUrl ?? process.env.REDIS_URL ?? "redis://localhost:6379"}) ` +
426 `— the burst assertion needs the worker's Redis`,
427 );
428 });
429 const STREAM = "factory:events";
430
431 const pipe = redis.pipeline();
432 for (let i = 0; i < count; i += 1) {
433 const pageId = pageIds[i % pageIds.length];
434 const payload = JSON.stringify({ workspaceId, pageId, fields: ["title"] });
435 pipe.xadd(STREAM, "*", "event", "page.updated", "payload", payload, "ts", new Date().toISOString());
436 }
437 const started = Date.now();
438 await pipe.exec();
439 burstMs = Date.now() - started;
440 } finally {
441 redis.disconnect();
442 }
443
444
445 const samples: BurstResult["samples"] = [];
446
447 while (true) {
448 const lag = await getSearchIndexLag();
449 samples.push({ tMs: Date.now() - t0, backlog: lag.available ? lag.backlog : -1 });
450 if (lag.available && lag.backlog === 0) {
451 return { published: count, burstMs, drainMs: Date.now() - t0, samples };
452 }
453 if (Date.now() - t0 >= timeoutMs) {
454 const curve = samples.map((s) => `${s.tMs}ms:${s.backlog}`).join(" ");
455 throw new Error(
456 `worker backlog did not drain within ${timeoutMs} ms after a ${count}-event burst ` +
457 `(search-index:lag samples tMs:backlog = ${curve}; -1 = endpoint unavailable)`,
458 );
459 }
460 await new Promise((r) => setTimeout(r, intervalMs));
461 }
462}
463
464
465
466
467
468
478export async function setPagePrivateDirectDb(pageId: string, isPrivate: boolean): Promise<boolean> {
479 const dsn = process.env.DATABASE_URL;
480 if (!dsn) {
481 throw new Error(
482 "setPagePrivateDirectDb: DATABASE_URL is not set — the private-page " +
483 "negative test needs the DB DSN (CI e2e job sets it; local dev .env).",
484 );
485 }
486 const prisma = new PrismaClient({ datasources: { db: { url: dsn } } });
487 try {
488 const updated = await prisma.page.update({
489 where: { id: pageId },
490 data: { isPrivate },
491 select: { isPrivate: true }
492 });
493 expect(updated.isPrivate, `setPagePrivateDirectDb(${pageId}): DB write`).toBe(isPrivate);
494 return updated.isPrivate;
495 } finally {
496 await prisma.$disconnect();
497 }
498}
499