304 lines
15 KiB
TypeScript
304 lines
15 KiB
TypeScript
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-010403 API契约 draft-2026-06-17-r0.
|
|
// Responsibility: Same-origin fake Workbench API and static server for Playwright browser regression tests.
|
|
|
|
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
import { readFile } from "node:fs/promises";
|
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
import { extname, join, resolve, sep } from "node:path";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
interface ConversationRecord extends JsonRecord {
|
|
conversationId: string;
|
|
sessionId?: string | null;
|
|
threadId?: string | null;
|
|
status?: string | null;
|
|
lastTraceId?: string | null;
|
|
messages?: JsonRecord[];
|
|
}
|
|
|
|
interface ScenarioState {
|
|
scenarioId: string;
|
|
projectId: string;
|
|
workspaceId: string;
|
|
providerProfile: string;
|
|
selectedConversationId: string;
|
|
conversations: ConversationRecord[];
|
|
traces: Record<string, JsonRecord>;
|
|
selectRequests: JsonRecord[];
|
|
listOmitSelected: boolean;
|
|
conversationDelayMs: number;
|
|
terminalScript: boolean;
|
|
}
|
|
|
|
const cwd = process.cwd();
|
|
const args = new Map<string, string>();
|
|
for (let index = 2; index < process.argv.length; index += 1) {
|
|
const key = process.argv[index];
|
|
const value = process.argv[index + 1];
|
|
if (key?.startsWith("--")) {
|
|
args.set(key.slice(2), value && !value.startsWith("--") ? value : "true");
|
|
if (value && !value.startsWith("--")) index += 1;
|
|
}
|
|
}
|
|
|
|
const port = Number(args.get("port") ?? process.env.HWLAB_WORKBENCH_E2E_PORT ?? 4173);
|
|
const distDir = resolve(cwd, args.get("dist") ?? "dist");
|
|
const capturePath = resolve(cwd, "tests/workbench-e2e/fixtures/real-captures/d601-v03-redacted.json");
|
|
const capture = JSON.parse(await readFile(capturePath, "utf8")) as { scenario: { projectId: string; workspaceId: string; selectedConversationId: string; providerProfile: string; conversations: ConversationRecord[]; traces: Record<string, JsonRecord> } };
|
|
|
|
let state = createScenarioState("baseline");
|
|
|
|
const server = createServer((request, response) => {
|
|
void handleRequest(request, response).catch((error) => {
|
|
json(response, 500, { ok: false, status: 500, error: { code: "e2e_server_error", message: error instanceof Error ? error.message : String(error) } });
|
|
});
|
|
});
|
|
|
|
server.listen(port, "127.0.0.1", () => {
|
|
console.log(`workbench-e2e-server listening on http://127.0.0.1:${port}`);
|
|
});
|
|
|
|
async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
const method = request.method ?? "GET";
|
|
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `127.0.0.1:${port}`}`);
|
|
const path = url.pathname;
|
|
|
|
if (path === "/__e2e/health") return json(response, 200, { ok: true, scenarioId: state.scenarioId });
|
|
if (path === "/__e2e/state") return json(response, 200, stateSummary());
|
|
if (path === "/__e2e/reset" && method === "POST") {
|
|
const body = await readJson(request);
|
|
state = createScenarioState(String(body.scenarioId ?? "baseline"));
|
|
return json(response, 200, stateSummary());
|
|
}
|
|
|
|
if (path === "/auth/session" || path === "/auth/bootstrap") return json(response, 200, authPayload());
|
|
if (path === "/v1/workbench/events" && method === "GET") return sse(response);
|
|
if (path === "/v1/workbench/workspace" && method === "GET") return json(response, 200, { workspace: workspacePayload() });
|
|
if (/^\/v1\/workbench\/workspace\/[^/]+$/u.test(path) && method === "PATCH") {
|
|
const body = await readJson(request);
|
|
if (typeof body.providerProfile === "string") state.providerProfile = body.providerProfile;
|
|
if (Object.hasOwn(body, "activeTraceId") && body.activeTraceId === null) clearActiveTraceForSelection();
|
|
return json(response, 200, { workspace: workspacePayload() });
|
|
}
|
|
if (/^\/v1\/workbench\/workspace\/[^/]+\/select-conversation$/u.test(path) && method === "POST") {
|
|
const body = await readJson(request);
|
|
state.selectRequests.push(redactRequestBody(body));
|
|
const conversationId = String(body.conversationId ?? "");
|
|
if (conversationId && conversationById(conversationId)) state.selectedConversationId = conversationId;
|
|
return json(response, 200, { workspace: workspacePayload() });
|
|
}
|
|
|
|
if (path === "/v1/agent/conversations" && method === "GET") {
|
|
await delay(state.conversationDelayMs);
|
|
const include = url.searchParams.get("includeConversationId");
|
|
const conversations = state.listOmitSelected && include ? state.conversations.filter((item) => item.conversationId !== include) : state.conversations;
|
|
return json(response, 200, { conversations: conversations.map((conversation) => conversationSummary(conversation)) });
|
|
}
|
|
const conversationMatch = path.match(/^\/v1\/agent\/conversations\/([^/]+)$/u);
|
|
if (conversationMatch && method === "GET") {
|
|
const conversation = conversationById(decodeURIComponent(conversationMatch[1] ?? ""));
|
|
return conversation ? json(response, 200, { conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "conversation_not_found" } });
|
|
}
|
|
if (conversationMatch && ["PUT", "DELETE"].includes(method)) return json(response, 200, { ok: true, workspace: workspacePayload() });
|
|
|
|
const sessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u);
|
|
if (sessionMatch && method === "GET") return json(response, 200, { session: sessionPayload(decodeURIComponent(sessionMatch[1] ?? "")) });
|
|
|
|
const turnMatch = path.match(/^\/v1\/agent\/turns\/([^/]+)$/u);
|
|
if (turnMatch && method === "GET") return json(response, 200, turnPayload(decodeURIComponent(turnMatch[1] ?? "")));
|
|
|
|
const traceMatch = path.match(/^\/v1\/agent\/traces\/([^/]+)$/u);
|
|
if (traceMatch && method === "GET") return json(response, 200, tracePayload(decodeURIComponent(traceMatch[1] ?? ""), url));
|
|
|
|
if (path === "/v1/provider-profiles") return json(response, 200, { profiles: [{ profile: "codex-api", name: "Codex API", configured: true }, { profile: "deepseek", name: "DeepSeek", configured: true }] });
|
|
if (path === "/health/live" || path === "/health" || path === "/v1") return json(response, 200, { status: "ok", serviceId: "workbench-e2e", codeAgent: { ready: true, status: "ready" } });
|
|
if (path === "/v1/live-builds") return json(response, 200, { status: "ok", builds: [] });
|
|
if (path === "/v1/hwpod/specs") return json(response, 200, { specs: [] });
|
|
if (path === "/v1/hwpod-node-ops") return json(response, 200, { ok: true, status: "ready", events: [], groups: [] });
|
|
if (path.startsWith("/v1/") || path.startsWith("/auth/") || path.startsWith("/health")) return json(response, 500, { ok: false, status: 500, error: { code: "unmocked_request", path } });
|
|
|
|
return staticFile(response, path);
|
|
}
|
|
|
|
function createScenarioState(scenarioId: string): ScenarioState {
|
|
const base = structuredClone(capture.scenario);
|
|
const id = scenarioId || "baseline";
|
|
const selectedConversationId = id === "deep-link" ? "cnv_failed" : base.selectedConversationId;
|
|
return {
|
|
scenarioId: id,
|
|
projectId: base.projectId,
|
|
workspaceId: base.workspaceId,
|
|
providerProfile: base.providerProfile,
|
|
selectedConversationId,
|
|
conversations: base.conversations,
|
|
traces: base.traces,
|
|
selectRequests: [],
|
|
listOmitSelected: id === "selected-missing-from-list",
|
|
conversationDelayMs: id === "loading" ? 2_500 : 0,
|
|
terminalScript: id === "event-replay" || id === "running-to-terminal"
|
|
};
|
|
}
|
|
|
|
function workspacePayload(): JsonRecord {
|
|
const selected = conversationById(state.selectedConversationId);
|
|
return {
|
|
workspaceId: state.workspaceId,
|
|
projectId: state.projectId,
|
|
revision: state.selectRequests.length + 1,
|
|
updatedAt: new Date().toISOString(),
|
|
selectedConversationId: state.selectedConversationId,
|
|
selectedAgentSessionId: selected?.sessionId ?? null,
|
|
activeTraceId: selected?.status === "running" ? selected.lastTraceId ?? null : null,
|
|
providerProfile: state.providerProfile,
|
|
workspace: {
|
|
projectId: state.projectId,
|
|
selectedConversationId: state.selectedConversationId,
|
|
selectedAgentSessionId: selected?.sessionId ?? null,
|
|
threadId: selected?.threadId ?? null,
|
|
sessionStatus: selected?.status ?? null,
|
|
providerProfile: state.providerProfile,
|
|
lastTraceId: selected?.lastTraceId ?? null,
|
|
updatedAt: new Date().toISOString()
|
|
}
|
|
};
|
|
}
|
|
|
|
function conversationSummary(conversation: ConversationRecord): ConversationRecord {
|
|
const { messages: _messages, ...rest } = conversation;
|
|
return rest as ConversationRecord;
|
|
}
|
|
|
|
function conversationById(id: string): ConversationRecord | null {
|
|
return state.conversations.find((conversation) => conversation.conversationId === id) ?? null;
|
|
}
|
|
|
|
function sessionPayload(sessionId: string): JsonRecord {
|
|
const conversation = state.conversations.find((item) => item.sessionId === sessionId);
|
|
return { sessionId, status: conversation?.status ?? "unknown", lastTraceId: conversation?.lastTraceId ?? null, updatedAt: new Date().toISOString() };
|
|
}
|
|
|
|
function turnPayload(traceId: string): JsonRecord {
|
|
const trace = state.traces[traceId] ?? { traceId, status: "unknown", events: [] };
|
|
const status = String(trace.status ?? "unknown");
|
|
return {
|
|
...trace,
|
|
traceId,
|
|
status,
|
|
running: ["running", "pending", "accepted"].includes(status),
|
|
terminal: ["completed", "failed", "blocked", "timeout", "canceled"].includes(status)
|
|
};
|
|
}
|
|
|
|
function tracePayload(traceId: string, url: URL): JsonRecord {
|
|
const turn = turnPayload(traceId);
|
|
const events = Array.isArray(turn.events) ? turn.events as JsonRecord[] : [];
|
|
const sinceSeq = Number(url.searchParams.get("sinceSeq") ?? 0);
|
|
const requestedLimit = Number(url.searchParams.get("limit") ?? events.length);
|
|
const limit = requestedLimit || events.length || 100;
|
|
const filtered = Number.isFinite(sinceSeq) && sinceSeq > 0 ? events.filter((event) => Number(event.seq ?? 0) > sinceSeq) : events;
|
|
const page = filtered.slice(0, Math.max(1, Number.isFinite(limit) ? limit : 100));
|
|
const lastSeq = Number(page.at(-1)?.seq ?? 0);
|
|
const hasMore = filtered.length > page.length;
|
|
return { ...turn, events: page, eventCount: events.length, hasMore, fullTraceLoaded: !hasMore, nextSinceSeq: hasMore ? lastSeq : null, range: { sinceSeq, returned: page.length, total: events.length } };
|
|
}
|
|
|
|
function sse(response: ServerResponse): void {
|
|
response.writeHead(200, {
|
|
"content-type": "text/event-stream; charset=utf-8",
|
|
"cache-control": "no-cache",
|
|
connection: "keep-alive"
|
|
});
|
|
writeSse(response, "workbench.connected", { type: "connected", projectId: state.projectId, workspaceId: state.workspaceId });
|
|
if (state.terminalScript) {
|
|
const scenarioId = state.scenarioId;
|
|
setTimeout(() => {
|
|
if (state.scenarioId !== scenarioId) return;
|
|
const event = { seq: 3, createdAt: new Date().toISOString(), label: "agentrun:assistant:message", type: "assistant_message", status: "completed", replyAuthority: true, final: true, message: "事件重放后完成。", terminal: true };
|
|
writeSse(response, "workbench.trace.event", { type: "trace.event", traceId: "trc_running", event, snapshot: { traceId: "trc_running", status: "completed", events: [event], eventCount: 3, fullTraceLoaded: true, finalResponse: { text: "事件重放后完成。" } } });
|
|
completeRunningConversation();
|
|
writeSse(response, "workbench.turn.snapshot", { type: "turn.snapshot", traceId: "trc_running", turn: turnPayload("trc_running") });
|
|
}, 350);
|
|
}
|
|
}
|
|
|
|
function writeSse(response: ServerResponse, eventName: string, payload: JsonRecord): void {
|
|
response.write(`event: ${eventName}\n`);
|
|
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
}
|
|
|
|
function completeRunningConversation(): void {
|
|
const conversation = conversationById("cnv_running");
|
|
if (!conversation) return;
|
|
conversation.status = "completed";
|
|
conversation.updatedAt = new Date().toISOString();
|
|
const trace = state.traces.trc_running ?? { traceId: "trc_running", status: "running", events: [] };
|
|
const finalEvent = { seq: 4, createdAt: new Date().toISOString(), label: "agentrun:terminal:completed", status: "completed", terminal: true };
|
|
const events = [...(Array.isArray(trace.events) ? trace.events as JsonRecord[] : []), finalEvent];
|
|
state.traces.trc_running = { ...trace, status: "completed", events, eventCount: events.length, fullTraceLoaded: true, hasMore: false, assistantText: "事件重放后完成。", finalResponse: { text: "事件重放后完成。" } };
|
|
conversation.messages = (conversation.messages ?? []).map((message) => message.role === "agent" ? { ...message, text: "事件重放后完成。", status: "completed", runnerTrace: state.traces.trc_running } : message);
|
|
}
|
|
|
|
function clearActiveTraceForSelection(): void {
|
|
const conversation = conversationById(state.selectedConversationId);
|
|
if (conversation?.status === "running") conversation.status = "failed";
|
|
}
|
|
|
|
function stateSummary(): JsonRecord {
|
|
return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, selectRequests: state.selectRequests, conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, status: item.status, lastTraceId: item.lastTraceId })) };
|
|
}
|
|
|
|
function authPayload(): JsonRecord {
|
|
return { authenticated: true, mode: "server", actor: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, user: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, expiresAt: null };
|
|
}
|
|
|
|
function redactRequestBody(value: JsonRecord): JsonRecord {
|
|
const output: JsonRecord = {};
|
|
for (const [key, item] of Object.entries(value)) output[key] = /secret|token|key|password|authorization/iu.test(key) ? "<redacted>" : item;
|
|
return output;
|
|
}
|
|
|
|
async function readJson(request: IncomingMessage): Promise<JsonRecord> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
if (chunks.length === 0) return {};
|
|
try {
|
|
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
return parsed && typeof parsed === "object" ? parsed as JsonRecord : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function json(response: ServerResponse, status: number, body: unknown): void {
|
|
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
response.end(JSON.stringify(body));
|
|
}
|
|
|
|
function staticFile(response: ServerResponse, path: string): void {
|
|
const filePath = safeStaticPath(path);
|
|
const target = filePath && existsSync(filePath) && statSync(filePath).isFile() ? filePath : join(distDir, "index.html");
|
|
response.writeHead(200, { "content-type": contentType(target) });
|
|
createReadStream(target).pipe(response);
|
|
}
|
|
|
|
function safeStaticPath(path: string): string | null {
|
|
const normalized = resolve(distDir, `.${decodeURIComponent(path === "/" ? "/index.html" : path)}`);
|
|
return normalized === distDir || normalized.startsWith(`${distDir}${sep}`) ? normalized : null;
|
|
}
|
|
|
|
function contentType(filePath: string): string {
|
|
switch (extname(filePath)) {
|
|
case ".html": return "text/html; charset=utf-8";
|
|
case ".js": return "text/javascript; charset=utf-8";
|
|
case ".css": return "text/css; charset=utf-8";
|
|
case ".svg": return "image/svg+xml";
|
|
case ".ico": return "image/x-icon";
|
|
default: return "application/octet-stream";
|
|
}
|
|
}
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return ms > 0 ? new Promise((resolveDelay) => setTimeout(resolveDelay, ms)) : Promise.resolve();
|
|
}
|