1
59import { chromium } from "playwright";
60import fs from "node:fs";
61import path from "node:path";
62import { fileURLToPath } from "node:url";
63
64
65
66
67
68const HERE = path.dirname(fileURLToPath(import.meta.url));
69
70const WEB = process.env.WEB_BASE_URL || "http://localhost:3000";
71const API = process.env.API_BASE_URL || "http://localhost:4000";
72const EMAIL = process.env.EMAIL || process.env.K6_EMAIL || "demo@factory.local";
73const PASSWORD = process.env.PASSWORD || process.env.K6_PASSWORD || "Factory!2345";
74const WORKSPACE = process.env.K6_WORKSPACE_ID || "";
75const SMOKE = process.env.K6_PROFILE === "smoke";
76const STRICT = (process.env.K6_STRICT || "1") !== "0";
77
78const TYPE_N = parseInt(process.env.EDITOR_TYPE || (SMOKE ? "20" : "500"), 10);
79const CREATE_N = parseInt(process.env.EDITOR_CREATE || (SMOKE ? "10" : "100"), 10);
80const MOVE_N = parseInt(process.env.EDITOR_MOVE || (SMOKE ? "10" : "200"), 10);
81
82
83const INTERACTION_P95 = 50;
84const MOVE_P95 = 16;
85const MOVE_P99 = 16;
86const FRAME_BUDGET_MS = 100;
87
88function pct(sorted, p) {
89 if (sorted.length === 0) return 0;
90 const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
91 return sorted[idx];
92}
93function sortedCopy(a) {
94 return [...a].sort((x, y) => x - y);
95}
96
97async function apiFetch(token, method, urlPath, body) {
98 const res = await fetch(`${API}/api/v1${urlPath}`, {
99 method,
100 headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
101 body: body === undefined ? undefined : JSON.stringify(body),
102 });
103 const text = await res.text();
104 let json = null;
105 try {
106 json = text ? JSON.parse(text) : null;
107 } catch {}
108 return { status: res.status, json, text };
109}
110
111async function main() {
112 const out = {
113 profile: SMOKE ? "smoke" : "full",
114 counts: { type: TYPE_N, create: CREATE_N, move: MOVE_N },
115 interactionLocal: [],
116 moveLocal: [],
117 createLocal: [],
118 serverCommit: [],
119 framesDuringMove: [],
120 errors: [],
121 passed: null,
122 };
123 const reportPath = path.resolve(HERE, "reports", "editor-local.json");
124
125
126
127 const loginRes = await fetch(`${API}/api/v1/auth/login`, {
128 method: "POST",
129 headers: { "Content-Type": "application/json" },
130 body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
131 });
132 if (loginRes.status !== 200) {
133 throw new Error(`login failed: ${loginRes.status} ${await loginRes.text()}`);
134 }
135 const loginBody = await loginRes.json();
136 const token = loginBody.accessToken || loginBody.access_token || loginBody.token;
137 if (!token) throw new Error("no token in login response");
138
139
140 let workspace = WORKSPACE;
141 if (!workspace) {
142 const wsRes = await apiFetch(token, "GET", "/workspaces");
143 const data = wsRes.json?.data || [];
144 if (data.length === 0) throw new Error("perf user has no workspaces");
145 workspace = data[0].id;
146 }
147
148
149
150 const pageRes = await apiFetch(token, "POST", `/workspaces/${workspace}/pages`, {
151 title: `Perf Editor ${Date.now()}`,
152 });
153 const pageId = pageRes.json?.id;
154 if (!pageId) throw new Error(`page create failed: ${pageRes.status} ${pageRes.text}`);
155 const blockIds = [];
156 for (let i = 0; i < 10; i++) {
157 const b = await apiFetch(token, "POST", `/pages/${pageId}/blocks`, {
158 type: "PARAGRAPH",
159 content: { text: `block ${i}` },
160 position: `a${i}`,
161 });
162 if (b.status >= 400) {
163
164
165 const b2 = await apiFetch(token, "POST", `/pages/${pageId}/blocks`, {
166 type: "PARAGRAPH",
167 content: { text: `block ${i}` },
168 });
169 if (b2.status >= 400) {
170 throw new Error(`seed block ${i} failed: ${b2.status} ${b2.text.slice(0, 160)} / ${b.text.slice(0, 160)}`);
171 }
172 blockIds.push(b2.json?.id);
173 } else {
174 blockIds.push(b.json?.id);
175 }
176 }
177 out.blockCount = blockIds.length;
178
179
180
181 let browser;
182 try {
183 browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"] });
184 } catch (err) {
185 out.passed = false;
186 out.errors.push(`chromium launch failed (headless unavailable in this sandbox — run on the CI runner): ${err.message?.slice(0, 200)}`);
187 return out;
188 }
189
190 try {
191
192
193
194
195
196
197
198
199
200
201
202
203 const context = await browser.newContext({ bypassCSP: true });
204
205
206
207
208 const lresp = await context.request.post(`${API}/api/v1/auth/login`, { data: { email: EMAIL, password: PASSWORD } });
209 if (lresp.status() !== 200) throw new Error(`web login failed: ${lresp.status()}`);
210
211
212 const page = await context.newPage();
213
214
215
216 await page.addInitScript(() => {
217 window.__perf = { commits: [], frames: [], moveActive: false, inputTs: 0 };
218 const origFetch = window.fetch;
219 window.fetch = function (input, init) {
220 const url = typeof input === "string" ? input : input?.url || "";
221 const m = window.__perf = window.__perf || { commits: [], frames: [], moveActive: false, inputTs: 0 };
222 const isCommit = /\/blocks\/batch$/.test(url) || /\/blocks\/[^/]+\/move$/.test(url) || /\/pages\/[^/]+\/blocks$/.test(url);
223 return origFetch.apply(this, arguments).then((res) => {
224 if (isCommit && res.ok) {
225 m.commits.push({ ts: performance.now(), url });
226 if (m.inputTs) {
227 m.lastServerLatency = performance.now() - m.inputTs;
228 m.inputTs = 0;
229 }
230 }
231 return res;
232 });
233 };
234
235
236 let last = performance.now();
237 const tick = (now) => {
238 const m = window.__perf;
239 const dt = now - last;
240 last = now;
241 if (m && m.moveActive) m.frames.push(dt);
242 requestAnimationFrame(tick);
243 };
244 requestAnimationFrame(tick);
245 });
246
247
248 const url = `${WEB}/w/${workspace}/page/${pageId}`;
249 await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
250
251 try {
252 await page.waitForSelector(".block-row[data-block-id]", { timeout: 60_000 });
253 } catch (e) {
254
255
256
257
258
259 const bodyText = await page.locator("body").innerText().catch(() => "");
260 if (/Loading workspace/.test(bodyText)) {
261 out.passed = false;
262 out.errors.push(
263 `web client never booted (stuck on "Loading workspace…"): committed CSP ` +
264 `(script-src 'self' 'unsafe-inline') rejects Next dev's eval-source-map chunks — ` +
265 `dev web is unbootable until the devtool/CSP conflict is resolved (see task report). ` +
266 `This suite needs the web UI to render before it can measure.`
267 );
268 return out;
269 }
270 throw e;
271 }
272 await page.waitForTimeout(1500);
273
274
275
276 const blockListHandle = page;
277
278
279 for (let i = 0; i < TYPE_N; i++) {
280 const row = i % 10;
281 const blockEl = page.locator(`.block-row[data-block-id]`).nth(row);
282 const editable = blockEl.locator("[contenteditable='true']").first();
283 try {
284
285
286 const baseLen = (await editable.textContent())?.length ?? 0;
287 await editable.click({ position: { x: 10, y: 10 } });
288 await page.evaluate(() => {
289 window.__perf.inputTs = performance.now();
290 });
291
292 await editable.pressSequentially("a", { delay: 0 });
293
294
295 const local = await timeLocalChange(page, blockEl, (len) => len >= baseLen + 1, () =>
296 editable.textContent().then((t) => (t ?? "").length)
297 );
298 out.interactionLocal.push(local);
299 } catch (e) {
300 out.errors.push(`type[${i}]: ${e.message?.slice(0, 120)}`);
301 }
302 }
303
304
305 for (let i = 0; i < CREATE_N; i++) {
306 const row = i % 10;
307 const blockEl = page.locator(`.block-row[data-block-id]`).nth(row);
308 try {
309 const editable = blockEl.locator("[contenteditable='true']").first();
310 await editable.click();
311
312 await page.evaluate(() => {
313 window.__perf.inputTs = performance.now();
314 });
315 await page.keyboard.press("End");
316 await page.keyboard.press("Enter");
317 const local = await timeLocalChange(page, page, (count) => count > 10, () =>
318 page.locator(".block-row[data-block-id]").count()
319 );
320 out.createLocal.push(local);
321 } catch (e) {
322 out.errors.push(`create[${i}]: ${e.message?.slice(0, 120)}`);
323 }
324 }
325
326
327
328
329
330 for (let i = 0; i < MOVE_N; i++) {
331 const from = i % 10;
332 const to = (i + 1) % 10;
333 try {
334 const rows = page.locator(".block-row[data-block-id]");
335 await rows.nth(from).hover();
336
337
338
339 const fromBox = await rows.nth(from).boundingBox();
340 const toBox = await rows.nth(to).boundingBox();
341 if (!fromBox || !toBox) continue;
342
343
344 const gripX = fromBox.x - 12;
345 const gripY = fromBox.y + fromBox.height / 2;
346 const targetX = toBox.x + toBox.width / 2;
347 const targetY = toBox.y + toBox.height / 2;
348
349 await page.evaluate(() => {
350 window.__perf.inputTs = performance.now();
351 window.__perf.moveActive = true;
352 });
353
354 await page.mouse.move(gripX, gripY);
355 await page.mouse.down();
356
357 const steps = 6;
358 for (let s = 1; s <= steps; s++) {
359 await page.mouse.move(gripX + ((targetX - gripX) * s) / steps, gripY + ((targetY - gripY) * s) / steps);
360 }
361 await page.mouse.up();
362 await page.evaluate(() => {
363 window.__perf.moveActive = false;
364 });
365
366
367 const local = await timeLocalChange(page, page, (count) => count >= 10, () =>
368 page.locator(".block-row[data-block-id]").count()
369 );
370 out.moveLocal.push(local);
371 } catch (e) {
372 out.errors.push(`move[${i}]: ${e.message?.slice(0, 120)}`);
373 }
374 if (i % 20 === 0) {
375 const fr = await page.evaluate(() => window.__perf.frames.splice(0));
376 out.framesDuringMove.push(...fr);
377 }
378 }
379
380 const fr = await page.evaluate(() => window.__perf.frames.splice(0));
381 out.framesDuringMove.push(...fr);
382
383
384 const commits = await page.evaluate(() => window.__perf.commits);
385 out.serverCommitCount = commits.length;
386
387
388 const interSorted = sortedCopy(out.interactionLocal);
389 const moveSorted = sortedCopy(out.moveLocal);
390 const summary = {
391 interaction: { n: interSorted.length, p50: pct(interSorted, 50), p95: pct(interSorted, 95), p99: pct(interSorted, 99), max: interSorted[interSorted.length - 1] || 0 },
392 move: { n: moveSorted.length, p50: pct(moveSorted, 50), p95: pct(moveSorted, 95), p99: pct(moveSorted, 99), max: moveSorted[moveSorted.length - 1] || 0 },
393 create: { n: out.createLocal.length, p95: pct(sortedCopy(out.createLocal), 95) },
394 framesDuringMove: { n: out.framesDuringMove.length, max: out.framesDuringMove.length ? Math.max(...out.framesDuringMove) : 0, over100ms: out.framesDuringMove.filter((f) => f > FRAME_BUDGET_MS).length },
395 errors: out.errors.length,
396 };
397 out.summary = summary;
398
399 const failures = [];
400 if (summary.interaction.p95 > INTERACTION_P95) failures.push(`interaction p95 ${summary.interaction.p95.toFixed(1)}ms > ${INTERACTION_P95}ms`);
401 if (summary.move.p95 > MOVE_P95) failures.push(`move p95 ${summary.move.p95.toFixed(1)}ms > ${MOVE_P95}ms`);
402 if (summary.move.p99 > MOVE_P99) failures.push(`move p99 ${summary.move.p99.toFixed(1)}ms > ${MOVE_P99}ms`);
403 if (summary.framesDuringMove.over100ms > 0) failures.push(`${summary.framesDuringMove.over100ms} frames > ${FRAME_BUDGET_MS}ms during move-block`);
404 out.failures = failures;
405 out.passed = failures.length === 0 && out.errors.length === 0;
406
407 if (!STRICT) out.passed = true;
408 out.thresholds = { interactionP95: INTERACTION_P95, moveP95: MOVE_P95, moveP99: MOVE_P99, frameBudgetMs: FRAME_BUDGET_MS, strict: STRICT };
409 } finally {
410 await browser.close().catch(() => {});
411 }
412
413 fs.mkdirSync(path.dirname(reportPath), { recursive: true });
414 fs.writeFileSync(reportPath, JSON.stringify(out, null, 2));
415 console.log(`\neditor-local report → ${reportPath}`);
416 console.log(JSON.stringify(out.summary, null, 2));
417 if (out.failures?.length) console.log("FAILURES:\n " + out.failures.join("\n "));
418 console.log(out.passed ? "PASSED" : out.errors.length ? `(${out.errors.length} errors — see report)` : "FAILED");
419 if (STRICT && !out.passed) process.exitCode = 1;
420 return out;
421}
422
423
429async function timeLocalChange(page, _root, isDone, probe) {
430 const start = await page.evaluate(() => window.__perf.inputTs || performance.now());
431 const deadline = Date.now() + 2000;
432 for (;;) {
433 const val = await probe().catch(() => 0);
434 if (isDone(val)) {
435 const end = await page.evaluate(() => performance.now());
436 return end - start;
437 }
438 if (Date.now() > deadline) {
439
440
441 return 2000;
442 }
443 await page.waitForTimeout(8);
444 }
445}
446
447main()
448 .catch((err) => {
449 console.error("editor-local failed:", err);
450 process.exitCode = 1;
451 });
452