mirror of
https://github.com/different-ai/openwork
synced 2026-04-26 01:25:10 +02:00
311 lines
8.7 KiB
JavaScript
311 lines
8.7 KiB
JavaScript
import assert from "node:assert";
|
|
import http from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
|
import { findFreePort, makeClient, parseArgs, spawnOpencodeServe, waitForHealthy } from "./_util.mjs";
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function writeSse(res, chunks) {
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
});
|
|
for (const chunk of chunks) {
|
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
}
|
|
res.write("data: [DONE]\n\n");
|
|
res.end();
|
|
}
|
|
|
|
function createTextStream(text) {
|
|
return [
|
|
{
|
|
id: "chatcmpl-1",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
|
},
|
|
{
|
|
id: "chatcmpl-1",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
|
},
|
|
{
|
|
id: "chatcmpl-1",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
},
|
|
];
|
|
}
|
|
|
|
function createInvalidToolStream() {
|
|
return [
|
|
{
|
|
id: "chatcmpl-2",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
|
},
|
|
{
|
|
id: "chatcmpl-2",
|
|
object: "chat.completion.chunk",
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: "call_1",
|
|
type: "function",
|
|
function: { name: "nonexistent_tool", arguments: "{}" },
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "chatcmpl-2",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
|
},
|
|
];
|
|
}
|
|
|
|
function hasChromeQuickstartPrompt(haystack) {
|
|
return (
|
|
haystack.includes("chrome devtools mcp") ||
|
|
haystack.includes("chrome-devtools_*") ||
|
|
haystack.includes("control chrome")
|
|
);
|
|
}
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const keepTmp = args.get("keep-tmp") === "true";
|
|
|
|
const results = {
|
|
ok: true,
|
|
steps: [],
|
|
};
|
|
|
|
function step(name, fn) {
|
|
results.steps.push({ name, status: "running" });
|
|
const idx = results.steps.length - 1;
|
|
return Promise.resolve()
|
|
.then(fn)
|
|
.then((data) => {
|
|
results.steps[idx] = { name, status: "ok", data };
|
|
})
|
|
.catch((e) => {
|
|
results.ok = false;
|
|
results.steps[idx] = {
|
|
name,
|
|
status: "error",
|
|
error: e instanceof Error ? e.message : String(e),
|
|
};
|
|
throw e;
|
|
});
|
|
}
|
|
|
|
let tmpdir;
|
|
let mock;
|
|
let opencode;
|
|
let sawChromeQuickstartPrompt = false;
|
|
const mockSockets = new Set();
|
|
|
|
try {
|
|
tmpdir = await mkdtemp(path.join(os.tmpdir(), "openwork-browser-entry-"));
|
|
|
|
const templateUrl = new URL("../src/app/data/commands/browser-setup.md", import.meta.url);
|
|
const template = await readFile(templateUrl, "utf8");
|
|
|
|
await step("workspace.setup", async () => {
|
|
await mkdir(path.join(tmpdir, ".opencode", "commands"), { recursive: true });
|
|
await writeFile(path.join(tmpdir, ".opencode", "commands", "browser-setup.md"), template, "utf8");
|
|
return { tmpdir };
|
|
});
|
|
|
|
const mockPort = await findFreePort();
|
|
const baseURL = `http://127.0.0.1:${mockPort}/v1`;
|
|
|
|
await step("provider.mock.start", async () => {
|
|
mock = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
|
|
if (req.method === "GET" && url.pathname.endsWith("/models")) {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
object: "list",
|
|
data: [{ id: "qwen-plus", object: "model" }],
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname.endsWith("/chat/completions")) {
|
|
const raw = await new Promise((resolve) => {
|
|
let data = "";
|
|
req.setEncoding("utf8");
|
|
req.on("data", (chunk) => (data += chunk));
|
|
req.on("end", () => resolve(data));
|
|
});
|
|
|
|
let body;
|
|
try {
|
|
body = raw ? JSON.parse(raw) : {};
|
|
} catch {
|
|
body = {};
|
|
}
|
|
|
|
const haystack = JSON.stringify(body).toLowerCase();
|
|
const triesChromeMcp = hasChromeQuickstartPrompt(haystack);
|
|
|
|
if (triesChromeMcp) {
|
|
sawChromeQuickstartPrompt = true;
|
|
writeSse(
|
|
res,
|
|
createTextStream(
|
|
"Trying Control Chrome first. If Chrome MCP is unavailable, open the MCP tab, connect Control Chrome, and retry.",
|
|
),
|
|
);
|
|
} else {
|
|
writeSse(res, createInvalidToolStream());
|
|
}
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
res.end("not found");
|
|
});
|
|
mock.on("connection", (socket) => {
|
|
mockSockets.add(socket);
|
|
socket.on("close", () => {
|
|
mockSockets.delete(socket);
|
|
});
|
|
});
|
|
|
|
await new Promise((resolve) => mock.listen(mockPort, "127.0.0.1", resolve));
|
|
return { baseURL };
|
|
});
|
|
|
|
await step("workspace.config", async () => {
|
|
await writeFile(
|
|
path.join(tmpdir, "opencode.json"),
|
|
JSON.stringify(
|
|
{
|
|
$schema: "https://opencode.ai/config.json",
|
|
enabled_providers: ["alibaba"],
|
|
provider: {
|
|
alibaba: {
|
|
options: {
|
|
apiKey: "test-key",
|
|
baseURL,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf8",
|
|
);
|
|
return {};
|
|
});
|
|
|
|
const port = await findFreePort();
|
|
opencode = await spawnOpencodeServe({ directory: tmpdir, port });
|
|
const client = makeClient({ baseUrl: opencode.baseUrl, directory: opencode.cwd });
|
|
|
|
await step("health", async () => {
|
|
const health = await waitForHealthy(client);
|
|
return health;
|
|
});
|
|
|
|
let sessionId;
|
|
|
|
await step("session.create", async () => {
|
|
const session = await client.session.create({ title: "OpenWork browser-entry test" });
|
|
sessionId = session.id;
|
|
assert.ok(sessionId);
|
|
return { id: session.id };
|
|
});
|
|
|
|
await step("session.command (browser-setup)", async () => {
|
|
await client.session.command({
|
|
sessionID: sessionId,
|
|
command: "browser-setup",
|
|
arguments: "",
|
|
model: "alibaba/qwen-plus",
|
|
});
|
|
return {};
|
|
});
|
|
|
|
await step("assert.chrome-mcp-quickstart", async () => {
|
|
assert.equal(sawChromeQuickstartPrompt, true, "Expected browser quickstart prompt to reference Chrome DevTools MCP");
|
|
return { sawChromeQuickstartPrompt };
|
|
});
|
|
|
|
await step("assert.no-tool-errors", async () => {
|
|
const start = Date.now();
|
|
// Keep this internal polling window short: the test should wait up to 12 seconds for the assistant response before failing
|
|
while (Date.now() - start < 12_000) {
|
|
const msgs = await client.session.messages({ sessionID: sessionId, limit: 50 });
|
|
const parts = msgs.flatMap((m) => m.parts ?? []);
|
|
const toolErrors = parts.filter((p) => p?.type === "tool" && String(p?.state?.status ?? "").toLowerCase() === "error");
|
|
if (toolErrors.length > 0) {
|
|
const first = toolErrors[0];
|
|
const tool = typeof first.tool === "string" ? first.tool : "tool";
|
|
const title = typeof first.state?.title === "string" ? first.state.title : "";
|
|
const err = typeof first.state?.error === "string" ? first.state.error : "";
|
|
throw new Error(`Unexpected tool error (${tool}): ${title} ${err}`.trim());
|
|
}
|
|
|
|
const hasAssistantText = msgs.some(
|
|
(m) => m.info?.role === "assistant" && (m.parts ?? []).some((p) => p.type === "text" && String(p.text ?? "").trim()),
|
|
);
|
|
if (hasAssistantText) {
|
|
return { messages: msgs.length };
|
|
}
|
|
|
|
await sleep(250);
|
|
}
|
|
throw new Error("Timed out waiting for assistant response");
|
|
});
|
|
|
|
console.log(JSON.stringify(results, null, 2));
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
results.ok = false;
|
|
results.error = message;
|
|
results.stderr = opencode?.getStderr?.() ?? "";
|
|
console.error(JSON.stringify(results, null, 2));
|
|
process.exitCode = 1;
|
|
} finally {
|
|
try {
|
|
if (opencode) await opencode.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
if (mock) {
|
|
for (const socket of mockSockets) {
|
|
socket.destroy();
|
|
}
|
|
await new Promise((resolve) => mock.close(() => resolve()));
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
if (tmpdir && !keepTmp) await rm(tmpdir, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|