fix: preserve trace scroll user control
This commit is contained in:
+155
-8
@@ -104,6 +104,8 @@ const LEFT_SIDEBAR_MAX_WIDTH = 180;
|
||||
const RIGHT_SIDEBAR_DEFAULT_WIDTH = 728;
|
||||
const RIGHT_SIDEBAR_MIN_WIDTH = 560;
|
||||
const RIGHT_SIDEBAR_MAX_WIDTH = 740;
|
||||
const SCROLL_BOTTOM_PIN_PX = 24;
|
||||
const SCROLL_USER_ACTIVITY_MS = 700;
|
||||
|
||||
const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view));
|
||||
let rpcSequence = 0;
|
||||
@@ -180,8 +182,11 @@ const state = {
|
||||
chatMessages: [],
|
||||
traceStreams: new Map(),
|
||||
traceDetailsOpen: new Map(),
|
||||
conversationRenderVersion: 0,
|
||||
conversationScrollUserActiveUntil: 0,
|
||||
conversationScrollPosition: { top: 0, left: 0 },
|
||||
traceScrollPositions: new Map(),
|
||||
traceScrollUserActiveUntil: new Map(),
|
||||
canceledTraces: new Set(),
|
||||
currentRequest: null,
|
||||
sessionStatus: null,
|
||||
@@ -225,6 +230,7 @@ initLiveBuildOverlay();
|
||||
initWorkbenchLogout(el.logoutButton);
|
||||
initM3Control();
|
||||
initGateControls();
|
||||
installWorkbenchTestHooks();
|
||||
renderStaticWorkbench();
|
||||
renderProbePending();
|
||||
renderCodeAgentSummary();
|
||||
@@ -794,8 +800,11 @@ function initCommandBar() {
|
||||
for (const close of state.traceStreams.values()) close();
|
||||
state.traceStreams.clear();
|
||||
state.traceDetailsOpen.clear();
|
||||
state.conversationRenderVersion += 1;
|
||||
state.conversationScrollUserActiveUntil = 0;
|
||||
state.conversationScrollPosition = { top: 0, left: 0 };
|
||||
state.traceScrollPositions.clear();
|
||||
state.traceScrollUserActiveUntil.clear();
|
||||
state.chatMessages = [];
|
||||
state.conversationId = null;
|
||||
state.sessionId = null;
|
||||
@@ -2043,6 +2052,7 @@ function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.res
|
||||
}
|
||||
|
||||
function renderConversation() {
|
||||
const renderVersion = ++state.conversationRenderVersion;
|
||||
captureConversationScrollPosition();
|
||||
captureTraceScrollPositions();
|
||||
const introMessages = [
|
||||
@@ -2058,28 +2068,45 @@ function renderConversation() {
|
||||
restoreConversationScrollPosition();
|
||||
restoreTraceScrollPositions();
|
||||
window.requestAnimationFrame(() => {
|
||||
restoreConversationScrollPosition();
|
||||
restoreTraceScrollPositions();
|
||||
if (renderVersion !== state.conversationRenderVersion) return;
|
||||
restoreConversationScrollPosition({ deferred: true });
|
||||
restoreTraceScrollPositions(el.conversationList, { deferred: true });
|
||||
});
|
||||
}
|
||||
|
||||
function initConversationScrollMemory() {
|
||||
for (const eventName of ["wheel", "touchstart", "pointerdown"]) {
|
||||
el.conversationList.addEventListener(eventName, markConversationScrollIntent, { passive: true });
|
||||
}
|
||||
el.conversationList.addEventListener("keydown", (event) => {
|
||||
if (isScrollIntentKey(event.key)) markConversationScrollIntent();
|
||||
});
|
||||
el.conversationList.addEventListener("scroll", () => {
|
||||
captureConversationScrollPosition();
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function markConversationScrollIntent() {
|
||||
state.conversationScrollUserActiveUntil = Date.now() + SCROLL_USER_ACTIVITY_MS;
|
||||
}
|
||||
|
||||
function isScrollIntentKey(key) {
|
||||
return ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(key);
|
||||
}
|
||||
|
||||
function captureConversationScrollPosition() {
|
||||
state.conversationScrollPosition = {
|
||||
top: el.conversationList.scrollTop,
|
||||
left: el.conversationList.scrollLeft
|
||||
left: el.conversationList.scrollLeft,
|
||||
bottomGap: scrollBottomGap(el.conversationList)
|
||||
};
|
||||
}
|
||||
|
||||
function restoreConversationScrollPosition() {
|
||||
function restoreConversationScrollPosition(options = {}) {
|
||||
if (options.deferred === true && Date.now() < state.conversationScrollUserActiveUntil) return;
|
||||
const position = state.conversationScrollPosition;
|
||||
if (!position) return;
|
||||
el.conversationList.scrollTop = Math.min(position.top, Math.max(0, el.conversationList.scrollHeight - el.conversationList.clientHeight));
|
||||
el.conversationList.scrollTop = scrollTopForPosition(el.conversationList, position);
|
||||
el.conversationList.scrollLeft = Math.min(position.left, Math.max(0, el.conversationList.scrollWidth - el.conversationList.clientWidth));
|
||||
}
|
||||
|
||||
@@ -2093,19 +2120,41 @@ function rememberTraceScrollPosition(traceUiKey, list) {
|
||||
if (!traceUiKey || !list) return;
|
||||
state.traceScrollPositions.set(traceUiKey, {
|
||||
top: list.scrollTop,
|
||||
left: list.scrollLeft
|
||||
left: list.scrollLeft,
|
||||
bottomGap: scrollBottomGap(list)
|
||||
});
|
||||
}
|
||||
|
||||
function restoreTraceScrollPositions(root = el.conversationList) {
|
||||
function restoreTraceScrollPositions(root = el.conversationList, options = {}) {
|
||||
for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) {
|
||||
if (options.deferred === true && isTraceScrollUserActive(list.dataset.traceUiKey)) continue;
|
||||
const position = state.traceScrollPositions.get(list.dataset.traceUiKey);
|
||||
if (!position) continue;
|
||||
list.scrollTop = Math.min(position.top, Math.max(0, list.scrollHeight - list.clientHeight));
|
||||
list.scrollTop = scrollTopForPosition(list, position);
|
||||
list.scrollLeft = Math.min(position.left, Math.max(0, list.scrollWidth - list.clientWidth));
|
||||
}
|
||||
}
|
||||
|
||||
function scrollBottomGap(element) {
|
||||
return Math.max(0, element.scrollHeight - element.clientHeight - element.scrollTop);
|
||||
}
|
||||
|
||||
function scrollTopForPosition(element, position) {
|
||||
const maxTop = Math.max(0, element.scrollHeight - element.clientHeight);
|
||||
if (Number(position.bottomGap) <= SCROLL_BOTTOM_PIN_PX) return maxTop;
|
||||
return Math.min(position.top, maxTop);
|
||||
}
|
||||
|
||||
function markTraceScrollIntent(traceUiKey) {
|
||||
if (!traceUiKey) return;
|
||||
state.traceScrollUserActiveUntil.set(traceUiKey, Date.now() + SCROLL_USER_ACTIVITY_MS);
|
||||
}
|
||||
|
||||
function isTraceScrollUserActive(traceUiKey) {
|
||||
if (!traceUiKey) return false;
|
||||
return Date.now() < Number(state.traceScrollUserActiveUntil.get(traceUiKey) ?? 0);
|
||||
}
|
||||
|
||||
function renderHardwareStatus(m3Status) {
|
||||
syncHardwareTabs();
|
||||
const status = m3Status ?? sourceFallbackM3Status();
|
||||
@@ -4158,6 +4207,12 @@ function messageTracePanel(message) {
|
||||
list.className = "message-trace-events";
|
||||
if (traceUiKey) {
|
||||
list.dataset.traceUiKey = traceUiKey;
|
||||
for (const eventName of ["wheel", "touchstart", "pointerdown"]) {
|
||||
list.addEventListener(eventName, () => markTraceScrollIntent(traceUiKey), { passive: true });
|
||||
}
|
||||
list.addEventListener("keydown", (event) => {
|
||||
if (isScrollIntentKey(event.key)) markTraceScrollIntent(traceUiKey);
|
||||
});
|
||||
list.addEventListener("scroll", () => rememberTraceScrollPosition(traceUiKey, list), { passive: true });
|
||||
}
|
||||
const events = Array.isArray(trace?.events) ? trace.events : [];
|
||||
@@ -4222,6 +4277,98 @@ function renderTraceEventList(list, rows) {
|
||||
}
|
||||
}
|
||||
|
||||
function installWorkbenchTestHooks() {
|
||||
if (!isLocalWorkbenchTestHost() || !new URLSearchParams(window.location.search).has("hwlab-test-hooks")) return;
|
||||
window.__hwlabWorkbenchTestHooks = {
|
||||
seedTraceMessage,
|
||||
appendTraceEvents,
|
||||
traceScrollMetrics,
|
||||
setTraceScrollTop,
|
||||
setConversationScrollTop
|
||||
};
|
||||
}
|
||||
|
||||
function isLocalWorkbenchTestHost() {
|
||||
return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(window.location.hostname);
|
||||
}
|
||||
|
||||
function seedTraceMessage(options = {}) {
|
||||
const traceId = options.traceId ?? "trc_scroll_contract";
|
||||
const messageId = options.messageId ?? "msg_scroll_contract";
|
||||
const count = Math.max(1, Number(options.count ?? 80));
|
||||
const events = Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1));
|
||||
state.chatMessages = [{
|
||||
id: messageId,
|
||||
role: "agent",
|
||||
title: "Code Agent 处理中",
|
||||
text: "Trace scroll contract fixture",
|
||||
status: "running",
|
||||
traceId,
|
||||
runnerTrace: {
|
||||
traceId,
|
||||
events,
|
||||
eventCount: events.length,
|
||||
lastEvent: events.at(-1),
|
||||
waitingFor: "turn/completed"
|
||||
}
|
||||
}];
|
||||
renderConversation();
|
||||
}
|
||||
|
||||
function appendTraceEvents(count = 1) {
|
||||
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId);
|
||||
if (!message) return;
|
||||
const trace = message.runnerTrace;
|
||||
const start = Array.isArray(trace.events) ? trace.events.length : 0;
|
||||
const additions = Array.from({ length: Math.max(1, Number(count)) }, (_, index) => testTraceEvent(trace.traceId, start + index + 1));
|
||||
trace.events = [...trace.events, ...additions];
|
||||
trace.eventCount = trace.events.length;
|
||||
trace.lastEvent = trace.events.at(-1);
|
||||
renderConversation();
|
||||
}
|
||||
|
||||
function testTraceEvent(traceId, seq) {
|
||||
return {
|
||||
traceId,
|
||||
label: "trace:scroll-contract",
|
||||
type: "trace",
|
||||
status: "observed",
|
||||
createdAt: new Date(1779690000000 + seq * 1000).toISOString(),
|
||||
message: `trace scroll contract event ${seq}\n${"payload ".repeat(18)}`,
|
||||
waitingFor: "turn/completed"
|
||||
};
|
||||
}
|
||||
|
||||
function traceScrollMetrics() {
|
||||
const conversation = el.conversationList;
|
||||
const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]");
|
||||
return {
|
||||
conversationTop: conversation.scrollTop,
|
||||
conversationBottomGap: scrollBottomGap(conversation),
|
||||
traceTop: list?.scrollTop ?? null,
|
||||
traceBottomGap: list ? scrollBottomGap(list) : null,
|
||||
traceScrollHeight: list?.scrollHeight ?? null,
|
||||
traceClientHeight: list?.clientHeight ?? null,
|
||||
traceRowCount: list?.querySelectorAll(".message-trace-row").length ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
function setTraceScrollTop(top, { user = true } = {}) {
|
||||
const list = el.conversationList.querySelector(".message-trace-events[data-trace-ui-key]");
|
||||
if (!list) return traceScrollMetrics();
|
||||
if (user) markTraceScrollIntent(list.dataset.traceUiKey);
|
||||
list.scrollTop = top;
|
||||
rememberTraceScrollPosition(list.dataset.traceUiKey, list);
|
||||
return traceScrollMetrics();
|
||||
}
|
||||
|
||||
function setConversationScrollTop(top, { user = true } = {}) {
|
||||
if (user) markConversationScrollIntent();
|
||||
el.conversationList.scrollTop = top;
|
||||
captureConversationScrollPosition();
|
||||
return traceScrollMetrics();
|
||||
}
|
||||
|
||||
function traceDisplayRows(trace, events) {
|
||||
const rows = [];
|
||||
let noisyRun = [];
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"m3-readonly": "node scripts/m3-readonly-contract.mjs",
|
||||
"check": "node --test code-agent-facts.test.mjs code-agent-status.test.mjs wiring-status.test.mjs code-agent-m3-evidence.test.mjs scripts/sidebar-resize.test.mjs && node scripts/check.mjs",
|
||||
"check": "node --test code-agent-facts.test.mjs code-agent-status.test.mjs wiring-status.test.mjs code-agent-m3-evidence.test.mjs scripts/sidebar-resize.test.mjs scripts/trace-scroll.test.mjs && node scripts/check.mjs",
|
||||
"build": "node scripts/build.mjs",
|
||||
"layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json",
|
||||
"layout:build": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json",
|
||||
|
||||
@@ -886,15 +886,24 @@ assert.match(app, /eventsCompacted:\s*snapshot\.eventsCompacted === true/);
|
||||
assert.match(app, /eventWindow:\s*snapshot\.eventWindow/);
|
||||
assert.match(app, /eventCount:\s*Number\.isInteger\(trace\?\.eventCount\)/);
|
||||
assert.match(app, /traceDetailsOpen:\s*new Map\(\)/);
|
||||
assert.match(app, /conversationRenderVersion:\s*0/);
|
||||
assert.match(app, /conversationScrollUserActiveUntil:\s*0/);
|
||||
assert.match(app, /conversationScrollPosition:\s*\{\s*top:\s*0,\s*left:\s*0\s*\}/);
|
||||
assert.match(app, /traceScrollPositions:\s*new Map\(\)/);
|
||||
assert.match(app, /traceScrollUserActiveUntil:\s*new Map\(\)/);
|
||||
assert.match(app, /state\.traceDetailsOpen\.clear\(\)/);
|
||||
assert.match(app, /state\.conversationRenderVersion \+= 1/);
|
||||
assert.match(app, /state\.conversationScrollPosition\s*=\s*\{\s*top:\s*0,\s*left:\s*0\s*\}/);
|
||||
assert.match(app, /state\.traceScrollPositions\.clear\(\)/);
|
||||
assert.match(app, /state\.traceScrollUserActiveUntil\.clear\(\)/);
|
||||
assert.match(app, /function messageTraceUiKey/);
|
||||
assert.match(app, /function defaultTraceDetailsOpen/);
|
||||
assert.match(app, /initConversationScrollMemory\(\)/);
|
||||
assert.match(app, /function initConversationScrollMemory/);
|
||||
assert.match(app, /function markConversationScrollIntent/);
|
||||
assert.match(app, /function markTraceScrollIntent/);
|
||||
assert.match(app, /function scrollBottomGap/);
|
||||
assert.match(app, /function scrollTopForPosition/);
|
||||
assert.match(app, /function captureConversationScrollPosition/);
|
||||
assert.match(app, /function restoreConversationScrollPosition/);
|
||||
assert.match(app, /function captureTraceScrollPositions/);
|
||||
@@ -905,10 +914,14 @@ assert.match(functionBody(app, "renderConversation"), /restoreConversationScroll
|
||||
assert.match(functionBody(app, "renderConversation"), /captureTraceScrollPositions\(\)/);
|
||||
assert.match(functionBody(app, "renderConversation"), /restoreTraceScrollPositions\(\)/);
|
||||
assert.match(functionBody(app, "renderConversation"), /requestAnimationFrame/);
|
||||
assert.match(functionBody(app, "renderConversation"), /renderVersion !== state\.conversationRenderVersion/);
|
||||
assert.match(functionBody(app, "restoreTraceScrollPositions"), /isTraceScrollUserActive/);
|
||||
assert.match(functionBody(app, "scrollTopForPosition"), /SCROLL_BOTTOM_PIN_PX/);
|
||||
assert.match(functionBody(app, "messageTracePanel"), /state\.traceDetailsOpen\.get\(traceUiKey\)/);
|
||||
assert.match(functionBody(app, "messageTracePanel"), /defaultTraceDetailsOpen\(message\)/);
|
||||
assert.match(functionBody(app, "messageTracePanel"), /state\.traceDetailsOpen\.set\(traceUiKey,\s*details\.open\)/);
|
||||
assert.match(functionBody(app, "messageTracePanel"), /list\.dataset\.traceUiKey\s*=\s*traceUiKey/);
|
||||
assert.match(functionBody(app, "messageTracePanel"), /markTraceScrollIntent\(traceUiKey\)/);
|
||||
assert.match(functionBody(app, "messageTracePanel"), /rememberTraceScrollPosition\(traceUiKey,\s*list\)/);
|
||||
assert.match(functionBody(app, "messageTracePanel"), /list\.dataset\.traceMode\s*=\s*"all"/);
|
||||
assert.match(functionBody(app, "messageTracePanel"), /renderTraceEventList\(list,\s*rows\)/);
|
||||
@@ -916,6 +929,8 @@ assert.doesNotMatch(app, /CODE_AGENT_TRACE_PREVIEW_LIMIT|tracePreviewRows|收起
|
||||
assert.match(app, /复制 JSON/);
|
||||
assert.match(app, /下载 trace/);
|
||||
assert.match(app, /function renderTraceEventList/);
|
||||
assert.match(app, /function installWorkbenchTestHooks/);
|
||||
assert.match(app, /hwlab-test-hooks/);
|
||||
assert.match(app, /function traceDisplayRows/);
|
||||
assert.match(app, /function traceDisplayRow/);
|
||||
assert.match(app, /function traceToolOutputSummaryRow/);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user