120 lines
5.6 KiB
JavaScript
120 lines
5.6 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createServer } from "node:http";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { chromium } from "playwright";
|
|
|
|
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const defaultCredentials = Object.freeze({
|
|
username: "admin",
|
|
password: "hwlab2026"
|
|
});
|
|
|
|
test("trace scroll stays user controlled while trace updates append", async () => {
|
|
const server = await startStaticServer(webRoot);
|
|
const browser = await chromium.launch({ headless: true });
|
|
try {
|
|
const page = await browser.newPage({
|
|
viewport: { width: 1366, height: 768 },
|
|
deviceScaleFactor: 1
|
|
});
|
|
await page.goto(`${server.url}?hwlab-test-hooks=1`, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
await login(page);
|
|
await page.waitForFunction(() => window.__hwlabWorkbenchTestHooks, null, { timeout: 12000 });
|
|
|
|
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.seedTraceMessage({ count: 90 }));
|
|
await page.waitForSelector(".message-trace-events[data-trace-ui-key]", { timeout: 12000 });
|
|
const initial = await traceMetrics(page);
|
|
assert.ok(initial.traceScrollHeight > initial.traceClientHeight + 200, JSON.stringify(initial));
|
|
|
|
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setTraceScrollTop(1_000_000, { user: false }));
|
|
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(8));
|
|
await page.waitForTimeout(80);
|
|
const pinned = await traceMetrics(page);
|
|
assert.ok(pinned.traceBottomGap <= 2, `trace should stay pinned at bottom when already at bottom: ${JSON.stringify(pinned)}`);
|
|
|
|
const userMiddle = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setTraceScrollTop(180, { user: true }));
|
|
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(12));
|
|
await page.waitForTimeout(120);
|
|
const afterAppend = await traceMetrics(page);
|
|
assert.ok(Math.abs(afterAppend.traceTop - userMiddle.traceTop) <= 8, `trace update fought the user's scroll position: before=${JSON.stringify(userMiddle)} after=${JSON.stringify(afterAppend)}`);
|
|
|
|
const userMoved = await page.evaluate(() => {
|
|
const before = window.__hwlabWorkbenchTestHooks.traceScrollMetrics();
|
|
return window.__hwlabWorkbenchTestHooks.setTraceScrollTop(before.traceTop + 180, { user: true });
|
|
});
|
|
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(12));
|
|
await page.waitForTimeout(120);
|
|
const afterUserMove = await traceMetrics(page);
|
|
assert.ok(afterUserMove.traceTop >= userMoved.traceTop - 8, `trace update should not snap back after user scrolls down: before=${JSON.stringify(userMoved)} after=${JSON.stringify(afterUserMove)}`);
|
|
|
|
const conversationMiddle = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setConversationScrollTop(40, { user: true }));
|
|
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(4));
|
|
await page.waitForTimeout(120);
|
|
const afterConversationAppend = await traceMetrics(page);
|
|
assert.ok(Math.abs(afterConversationAppend.conversationTop - conversationMiddle.conversationTop) <= 8, `conversation scroll should not snap on trace update: before=${JSON.stringify(conversationMiddle)} after=${JSON.stringify(afterConversationAppend)}`);
|
|
|
|
await page.close();
|
|
} finally {
|
|
await browser.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
async function traceMetrics(page) {
|
|
return page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceScrollMetrics());
|
|
}
|
|
|
|
async function login(page) {
|
|
await page.waitForSelector("#login-shell, [data-app-shell]", { timeout: 12000 });
|
|
const loginVisible = await page.locator("#login-shell").isVisible().catch(() => false);
|
|
if (!loginVisible) {
|
|
await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 });
|
|
return;
|
|
}
|
|
await page.locator("#login-username").fill(defaultCredentials.username);
|
|
await page.locator("#login-password").fill(defaultCredentials.password);
|
|
await page.locator("#login-submit").click();
|
|
await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 });
|
|
}
|
|
|
|
async function startStaticServer(rootDir) {
|
|
const server = createServer(async (request, response) => {
|
|
try {
|
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
const resolved = path.resolve(rootDir, `.${pathname}`);
|
|
if (!resolved.startsWith(rootDir)) {
|
|
response.writeHead(403).end("forbidden");
|
|
return;
|
|
}
|
|
const body = await readFile(resolved);
|
|
response.writeHead(200, { "content-type": contentType(resolved) });
|
|
response.end(body);
|
|
} catch {
|
|
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
response.end("not found");
|
|
}
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const address = server.address();
|
|
return {
|
|
url: `http://127.0.0.1:${address.port}/`,
|
|
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
|
};
|
|
}
|
|
|
|
function contentType(filePath) {
|
|
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
|
|
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
|
|
if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
|
|
if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8";
|
|
return "application/octet-stream";
|
|
}
|