571 lines
23 KiB
JavaScript
571 lines
23 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("final assistant response renders basic markdown without raw html injection", async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const server = await startStaticServer(webRoot);
|
|
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.seedCompletedAgentMessage({
|
|
text: [
|
|
"# Gateway 控制 Windows CMD 的方法",
|
|
"",
|
|
"核心工具是 `/app/tools/hwlab-gateway-shell.mjs`。",
|
|
"",
|
|
"---",
|
|
"",
|
|
"| 参数 | 作用 |",
|
|
"|---|---|",
|
|
"| `--json` | 输出 JSON |",
|
|
"",
|
|
"```bash",
|
|
"node /app/tools/hwlab-gateway-shell.mjs --json",
|
|
"```",
|
|
"",
|
|
"<span class=\"message-copy\">raw html should stay inert</span>",
|
|
"[blocked](javascript:alert(1))"
|
|
].join("\n")
|
|
}));
|
|
|
|
const markdown = page.locator(".message-card.message-agent .message-copy-markdown");
|
|
await markdown.waitFor({ timeout: 12000 });
|
|
await expectCount(page, ".message-copy-markdown h1", 1);
|
|
await expectCount(page, ".message-copy-markdown table", 1);
|
|
await expectCount(page, ".message-copy-markdown pre code", 1);
|
|
await expectCount(page, ".message-copy-markdown .message-copy", 0);
|
|
await expectCount(page, ".message-copy-markdown a[href^='javascript:']", 0);
|
|
|
|
const renderedText = await markdown.innerText();
|
|
assert.match(renderedText, /Gateway 控制 Windows CMD 的方法/);
|
|
assert.match(renderedText, /--json/);
|
|
assert.match(renderedText, /raw html should stay inert/);
|
|
|
|
await page.close();
|
|
} finally {
|
|
await browser.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("trace scroll stays user controlled while trace updates append", async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const server = await startStaticServer(webRoot);
|
|
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();
|
|
}
|
|
});
|
|
|
|
test("trace display full means complete readable timeline, not compacted result window", async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const server = await startStaticServer(webRoot);
|
|
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(() => {
|
|
const gatewayPayload = (id, operationId, status = "succeeded") => JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
result: {
|
|
accepted: true,
|
|
status,
|
|
operationId,
|
|
gatewaySessionId: "gws_test",
|
|
resourceId: "res_windows_host",
|
|
capabilityId: "cap_windows_cmd_exec",
|
|
dispatch: {
|
|
shellExecuted: true,
|
|
dispatchStatus: status,
|
|
exitCode: status === "succeeded" ? 0 : 1,
|
|
durationMs: 1200,
|
|
command: "powershell -EncodedCommand <redacted>",
|
|
stdout: `stdout for ${operationId}`,
|
|
stderr: ""
|
|
}
|
|
}
|
|
});
|
|
window.__hwlabWorkbenchTestHooks.seedTraceMessage({
|
|
eventsCompacted: true,
|
|
eventCount: 600,
|
|
events: [
|
|
{
|
|
label: "request:accepted-short-connection",
|
|
status: "started",
|
|
message: "accepted"
|
|
},
|
|
{
|
|
label: "item/commandExecution/outputDelta",
|
|
type: "tool_call",
|
|
status: "output_chunk",
|
|
outputSummary: `${gatewayPayload("req_1", "op_one")}${gatewayPayload("req_2", "op_two")}`
|
|
}
|
|
],
|
|
assistantStreams: [
|
|
{
|
|
label: "assistant:stream",
|
|
status: "streaming",
|
|
chunkCount: 3,
|
|
text: "first readable sentence middle detail final conclusion",
|
|
updatedAt: "2026-05-26T12:29:28.000Z",
|
|
waitingFor: "turn/completed"
|
|
}
|
|
]
|
|
});
|
|
});
|
|
await page.waitForSelector(".message-trace-count", { timeout: 12000 });
|
|
const replaying = await tracePanelText(page);
|
|
assert.match(replaying.count, /完整 trace 回放中/);
|
|
assert.doesNotMatch(replaying.count, /压缩窗口|显示全部/);
|
|
assert.equal(replaying.rows.filter((row) => row.includes("tool gateway.shell")).length, 2, replaying.rows.join("\n---\n"));
|
|
assert.ok(replaying.rows.some((row) => row.includes("op=op_one")), replaying.rows.join("\n"));
|
|
assert.ok(replaying.rows.some((row) => row.includes("op=op_two")), replaying.rows.join("\n"));
|
|
assert.match(replaying.bodies.join("\n"), /first readable sentence middle detail final conclusion/);
|
|
|
|
await page.evaluate(() => {
|
|
const message = window.__hwlabWorkbenchTestHooks.tracePanelText();
|
|
const rows = message.rows.length;
|
|
window.__hwlabWorkbenchTestHooks.seedTraceMessage({
|
|
fullTraceLoaded: true,
|
|
events: Array.from({ length: rows }, (_, index) => ({
|
|
label: `full:readable:${index + 1}`,
|
|
status: "completed",
|
|
message: `full readable event ${index + 1}`
|
|
}))
|
|
});
|
|
});
|
|
const full = await tracePanelText(page);
|
|
assert.match(full.count, /显示全部可读事件/);
|
|
assert.doesNotMatch(full.count, /完整 trace 回放中|压缩窗口/);
|
|
|
|
await page.close();
|
|
} finally {
|
|
await browser.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("trace result-ready fixture is reconciled into final assistant result", async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const server = await startStaticServer(webRoot);
|
|
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({
|
|
traceId: "trc_fixture_result_ready",
|
|
events: [{
|
|
label: "result:completed",
|
|
type: "result",
|
|
status: "completed",
|
|
terminal: true,
|
|
message: "Code Agent result is ready for short-connection polling."
|
|
}]
|
|
});
|
|
});
|
|
const before = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText());
|
|
assert.equal(before.status, "running");
|
|
assert.match(before.text, /Trace scroll contract fixture/);
|
|
|
|
const after = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.reconcileTraceResultFixture({
|
|
traceId: "trc_fixture_result_ready",
|
|
conversationId: "cnv_fixture_result_ready",
|
|
sessionId: "ses_fixture_result_ready",
|
|
status: "completed",
|
|
provider: "codex-stdio",
|
|
model: "gpt-test",
|
|
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
|
capabilityLevel: "long-lived-codex-stdio-session",
|
|
runner: { kind: "codex-app-server-stdio-runner" },
|
|
session: {
|
|
sessionId: "ses_fixture_result_ready",
|
|
status: "idle",
|
|
idleTimeoutMs: 1800000,
|
|
lastTraceId: "trc_fixture_result_ready",
|
|
longLivedSession: true,
|
|
durableSession: true,
|
|
codexStdio: true,
|
|
writeCapable: true
|
|
},
|
|
sessionMode: "codex-app-server-stdio-long-lived",
|
|
implementationType: "repo-owned-codex-app-server-stdio-session",
|
|
sessionReuse: { reused: true, sessionId: "ses_fixture_result_ready" },
|
|
longLivedSessionGate: { status: "pass", pass: true },
|
|
reply: { content: "最终回复来自 result endpoint,而不是 result-ready trace 文案。" },
|
|
providerTrace: {
|
|
protocol: "codex-app-server-jsonrpc-stdio",
|
|
command: "codex app-server --listen stdio://",
|
|
terminalStatus: "completed",
|
|
threadId: "thread_fixture",
|
|
turnId: "turn_fixture"
|
|
}
|
|
}));
|
|
assert.equal(after.status, "completed", JSON.stringify(after));
|
|
assert.match(after.text, /最终回复来自 result endpoint/);
|
|
assert.doesNotMatch(after.text, /Code Agent result is ready for short-connection polling/);
|
|
assert.equal(after.providerTrace.terminalStatus, "completed");
|
|
|
|
const cardText = await page.locator(".message-card.message-agent").last().innerText();
|
|
assert.match(cardText, /最终回复来自 result endpoint/);
|
|
assert.doesNotMatch(cardText, /Code Agent result is ready for short-connection polling\.$/);
|
|
|
|
await page.close();
|
|
} finally {
|
|
await browser.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("restored pending trace polls result endpoint without token or live provider", async () => {
|
|
const traceId = "trc_restore_result_ready";
|
|
const browser = await chromium.launch({ headless: true });
|
|
const server = await startStaticServer(webRoot, {
|
|
[`/v1/agent/chat/result/${traceId}`]: completedResultFixture(traceId, "刷新恢复后来自 result endpoint 的回复。"),
|
|
[`/v1/agent/chat/trace/${traceId}`]: {
|
|
traceId,
|
|
status: "completed",
|
|
eventCount: 1,
|
|
events: [{
|
|
seq: 1,
|
|
traceId,
|
|
label: "result:completed",
|
|
type: "result",
|
|
status: "completed",
|
|
terminal: true,
|
|
message: "Code Agent result is ready for short-connection polling."
|
|
}],
|
|
lastEvent: {
|
|
label: "result:completed",
|
|
type: "result",
|
|
status: "completed",
|
|
terminal: true
|
|
}
|
|
}
|
|
});
|
|
try {
|
|
const page = await browser.newPage({
|
|
viewport: { width: 1366, height: 768 },
|
|
deviceScaleFactor: 1
|
|
});
|
|
await page.addInitScript(({ traceId: injectedTraceId }) => {
|
|
window.localStorage.setItem("hwlab.workbench.codeAgentSession.v1", JSON.stringify({
|
|
version: 1,
|
|
updatedAtMs: Date.now(),
|
|
conversationId: "cnv_restore_result_ready",
|
|
sessionId: "ses_restore_result_ready",
|
|
threadId: "thread_restore_result_ready",
|
|
sessionStatus: "busy",
|
|
chatMessages: [{
|
|
id: "msg_restore_result_ready",
|
|
role: "agent",
|
|
title: "Code Agent 处理中",
|
|
text: "正在处理这次 Code Agent 请求",
|
|
status: "running",
|
|
traceId: injectedTraceId,
|
|
conversationId: "cnv_restore_result_ready",
|
|
sessionId: "ses_restore_result_ready",
|
|
threadId: "thread_restore_result_ready",
|
|
retryInput: "restore fixture"
|
|
}]
|
|
}));
|
|
}, { traceId });
|
|
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.waitForFunction(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText()?.status === "completed", null, { timeout: 12000 });
|
|
const restored = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText());
|
|
assert.equal(restored.status, "completed", JSON.stringify(restored));
|
|
assert.match(restored.text, /刷新恢复后来自 result endpoint/);
|
|
assert.doesNotMatch(restored.text, /Code Agent result is ready for short-connection polling/);
|
|
|
|
await page.close();
|
|
} finally {
|
|
await browser.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
|
|
test("restored client idle timeout is corrected by completed result polling", async () => {
|
|
const traceId = "trc_restore_client_idle_timeout_completed";
|
|
const server = await startStaticServer(webRoot, {
|
|
[`/v1/agent/chat/result/${traceId}`]: completedResultFixture(traceId, "超时后端补齐的最终回复。"),
|
|
[`/v1/agent/chat/trace/${traceId}`]: {
|
|
traceId,
|
|
status: "completed",
|
|
eventCount: 1,
|
|
events: [{
|
|
seq: 1,
|
|
traceId,
|
|
label: "result:completed",
|
|
type: "result",
|
|
status: "completed",
|
|
terminal: true,
|
|
message: "Code Agent result is ready for short-connection polling."
|
|
}],
|
|
lastEvent: {
|
|
label: "result:completed",
|
|
type: "result",
|
|
status: "completed",
|
|
terminal: true
|
|
}
|
|
}
|
|
});
|
|
const browser = await chromium.launch({ headless: true });
|
|
try {
|
|
const page = await browser.newPage({
|
|
viewport: { width: 1366, height: 768 },
|
|
deviceScaleFactor: 1
|
|
});
|
|
await page.addInitScript(({ traceId: injectedTraceId }) => {
|
|
window.localStorage.setItem("hwlab.workbench.codeAgentSession.v1", JSON.stringify({
|
|
version: 1,
|
|
updatedAtMs: Date.now(),
|
|
conversationId: "cnv_restore_client_idle_timeout_completed",
|
|
sessionId: "ses_restore_client_idle_timeout_completed",
|
|
threadId: "thread_restore_client_idle_timeout_completed",
|
|
sessionStatus: "timeout",
|
|
chatMessages: [{
|
|
id: "msg_restore_client_idle_timeout_completed",
|
|
role: "agent",
|
|
title: "Code Agent 无新事件超时",
|
|
text: "Code Agent 超过 600000ms 无新事件;输入已保留,可稍后重试。",
|
|
status: "timeout",
|
|
traceId: injectedTraceId,
|
|
conversationId: "cnv_restore_client_idle_timeout_completed",
|
|
sessionId: "ses_restore_client_idle_timeout_completed",
|
|
threadId: "thread_restore_client_idle_timeout_completed",
|
|
retryInput: "restore timeout fixture",
|
|
error: {
|
|
code: "client_trace_idle_timeout",
|
|
category: "timeout",
|
|
timeoutMs: 600000,
|
|
message: "Code Agent 超过 600000ms 无新事件"
|
|
},
|
|
runnerTrace: {
|
|
traceId: injectedTraceId,
|
|
eventCount: 1,
|
|
events: [{
|
|
label: "result:completed",
|
|
type: "result",
|
|
status: "completed",
|
|
terminal: true
|
|
}],
|
|
lastEvent: { label: "result:completed", status: "completed" }
|
|
}
|
|
}]
|
|
}));
|
|
}, { traceId });
|
|
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.waitForFunction(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText()?.status === "completed", null, { timeout: 12000 });
|
|
const restored = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText());
|
|
assert.equal(restored.status, "completed", JSON.stringify(restored));
|
|
assert.match(restored.text, /超时后端补齐的最终回复/);
|
|
assert.equal(restored.providerTrace.terminalStatus, "completed");
|
|
assert.equal(restored.error, null);
|
|
|
|
await page.close();
|
|
} finally {
|
|
await browser.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
async function traceMetrics(page) {
|
|
return page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceScrollMetrics());
|
|
}
|
|
|
|
async function tracePanelText(page) {
|
|
return page.evaluate(() => window.__hwlabWorkbenchTestHooks.tracePanelText());
|
|
}
|
|
|
|
async function expectCount(page, selector, expected) {
|
|
assert.equal(await page.locator(selector).count(), expected, selector);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
function completedResultFixture(traceId, content) {
|
|
return {
|
|
traceId,
|
|
conversationId: "cnv_fixture_result_ready",
|
|
sessionId: "ses_fixture_result_ready",
|
|
status: "completed",
|
|
provider: "codex-stdio",
|
|
model: "gpt-test",
|
|
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
|
workspace: "/workspace/hwlab",
|
|
sandbox: "workspace-write",
|
|
capabilityLevel: "long-lived-codex-stdio-session",
|
|
reply: { content },
|
|
runner: {
|
|
kind: "codex-app-server-stdio-runner",
|
|
codexStdio: true,
|
|
writeCapable: true,
|
|
durableSession: true,
|
|
workspace: "/workspace/hwlab",
|
|
sandbox: "workspace-write",
|
|
sessionMode: "codex-app-server-stdio-long-lived",
|
|
implementationType: "repo-owned-codex-app-server-stdio-session",
|
|
capabilityLevel: "long-lived-codex-stdio-session"
|
|
},
|
|
session: {
|
|
sessionId: "ses_fixture_result_ready",
|
|
status: "idle",
|
|
idleTimeoutMs: 1800000,
|
|
lastTraceId: traceId,
|
|
longLivedSession: true,
|
|
durableSession: true,
|
|
codexStdio: true,
|
|
writeCapable: true
|
|
},
|
|
sessionMode: "codex-app-server-stdio-long-lived",
|
|
implementationType: "repo-owned-codex-app-server-stdio-session",
|
|
sessionReuse: { reused: true, sessionId: "ses_fixture_result_ready" },
|
|
longLivedSessionGate: { status: "pass", pass: true },
|
|
toolCalls: [{ name: "codex-app-server.thread/resume+turn/start", status: "completed" }],
|
|
skills: [],
|
|
providerTrace: {
|
|
protocol: "codex-app-server-jsonrpc-stdio",
|
|
command: "codex app-server --listen stdio://",
|
|
terminalStatus: "completed",
|
|
threadId: "thread_fixture",
|
|
turnId: "turn_fixture"
|
|
},
|
|
runnerTrace: {
|
|
traceId,
|
|
events: [],
|
|
eventCount: 0,
|
|
lastEvent: null
|
|
},
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
async function startStaticServer(rootDir, jsonRoutes = {}) {
|
|
const server = createServer(async (request, response) => {
|
|
try {
|
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
if (Object.hasOwn(jsonRoutes, url.pathname)) {
|
|
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
response.end(JSON.stringify(jsonRoutes[url.pathname]));
|
|
return;
|
|
}
|
|
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";
|
|
}
|