|
| 1 | +// Subprocess tests for the executable gray-screen tools. Run: node --test scripts/gray-screen/__tests__/tools.test.mjs |
| 2 | +// The browser harnesses (webview-heap-matrix, webview-render-stress) need a built webview and Chromium, so only |
| 3 | +// their pure parts (static serving, build directory) are covered, in lib.test.mjs. |
| 4 | +import assert from "node:assert/strict" |
| 5 | +import { spawn, spawnSync } from "node:child_process" |
| 6 | +import fs from "node:fs" |
| 7 | +import net from "node:net" |
| 8 | +import os from "node:os" |
| 9 | +import path from "node:path" |
| 10 | +import { afterEach, beforeEach, describe, it } from "node:test" |
| 11 | +import { fileURLToPath } from "node:url" |
| 12 | + |
| 13 | +const dir = path.dirname(fileURLToPath(import.meta.url)) |
| 14 | +const script = (name) => path.join(dir, "..", name) |
| 15 | +const node = (name, args) => spawnSync(process.execPath, [script(name), ...args], { encoding: "utf8" }) |
| 16 | + |
| 17 | +let tmp |
| 18 | + |
| 19 | +beforeEach(() => { |
| 20 | + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gray-screen-tools-")) |
| 21 | +}) |
| 22 | + |
| 23 | +afterEach(() => { |
| 24 | + fs.rmSync(tmp, { recursive: true, force: true }) |
| 25 | +}) |
| 26 | + |
| 27 | +const generate = (...args) => node("generate-large-task.mjs", ["--storage", tmp, ...args]) |
| 28 | + |
| 29 | +function readTask() { |
| 30 | + const [id] = fs.readdirSync(path.join(tmp, "tasks")) |
| 31 | + const read = (name) => JSON.parse(fs.readFileSync(path.join(tmp, "tasks", id, name), "utf8")) |
| 32 | + return { id, messages: read("ui_messages.json"), api: read("api_conversation_history.json"), item: read("history_item.json") } |
| 33 | +} |
| 34 | + |
| 35 | +describe("generate-large-task", () => { |
| 36 | + it("writes a complete task with the requested number of messages", () => { |
| 37 | + const result = generate("--messages", "40", "--text-bytes", "100") |
| 38 | + |
| 39 | + assert.equal(result.status, 0, result.stderr) |
| 40 | + const { id, messages, item } = readTask() |
| 41 | + assert.ok(messages.length >= 40) |
| 42 | + assert.equal(item.id, id) |
| 43 | + assert.match(item.task, /\[LOAD TEST\]/) |
| 44 | + assert.deepEqual(fs.readdirSync(tmp).sort(), ["tasks"]) |
| 45 | + }) |
| 46 | + |
| 47 | + it("adds images when asked to", () => { |
| 48 | + assert.equal(generate("--messages", "30", "--text-bytes", "100", "--image-every", "5", "--image-kb", "1").status, 0) |
| 49 | + |
| 50 | + assert.ok(readTask().messages.some((m) => m.images?.length)) |
| 51 | + }) |
| 52 | + |
| 53 | + it("produces non-Latin-1 text with --two-byte true only", () => { |
| 54 | + assert.equal(generate("--messages", "30", "--text-bytes", "100", "--two-byte", "true").status, 0) |
| 55 | + assert.match(JSON.stringify(readTask().messages), /한글/) |
| 56 | + |
| 57 | + fs.rmSync(path.join(tmp, "tasks"), { recursive: true }) |
| 58 | + assert.equal(generate("--messages", "30", "--text-bytes", "100").status, 0) |
| 59 | + assert.doesNotMatch(JSON.stringify(readTask().messages), /한글/) |
| 60 | + }) |
| 61 | + |
| 62 | + it("creates nothing when a flag has no value or is not a positive integer", () => { |
| 63 | + for (const args of [["--messages", "--text-bytes", "5"], ["--messages", "abc"], ["--messages", "0"], ["--image-kb", "-1"]]) { |
| 64 | + const result = generate(...args) |
| 65 | + |
| 66 | + assert.notEqual(result.status, 0, args.join(" ")) |
| 67 | + assert.deepEqual(fs.readdirSync(tmp), [], args.join(" ")) |
| 68 | + } |
| 69 | + }) |
| 70 | +}) |
| 71 | + |
| 72 | +describe("analyze-session", () => { |
| 73 | + const analyze = (...args) => node("analyze-session.mjs", ["--storage", tmp, ...args]) |
| 74 | + |
| 75 | + it("excludes generated [LOAD TEST] tasks unless asked, and reports sizes for the rest", () => { |
| 76 | + assert.equal(generate("--messages", "40", "--text-bytes", "100").status, 0) |
| 77 | + |
| 78 | + const excluded = analyze() |
| 79 | + assert.equal(excluded.status, 0, excluded.stderr) |
| 80 | + assert.match(excluded.stdout, /excluded 1 generated/) |
| 81 | + assert.match(excluded.stdout, /analyzed set: 0/) |
| 82 | + |
| 83 | + const included = analyze("--include-generated", "true") |
| 84 | + assert.equal(included.status, 0, included.stderr) |
| 85 | + assert.match(included.stdout, /analyzed set: 1/) |
| 86 | + assert.match(included.stdout, /size\(MB\)\s+msgs\s+toolTxt/) |
| 87 | + }) |
| 88 | + |
| 89 | + it("does not print message content", () => { |
| 90 | + const taskDir = path.join(tmp, "tasks", "real-task") |
| 91 | + fs.mkdirSync(taskDir, { recursive: true }) |
| 92 | + fs.writeFileSync( |
| 93 | + path.join(taskDir, "ui_messages.json"), |
| 94 | + JSON.stringify([{ ts: 1, type: "say", say: "text", text: "TOP-SECRET-CONTENT" }]), |
| 95 | + ) |
| 96 | + |
| 97 | + const result = analyze() |
| 98 | + |
| 99 | + assert.equal(result.status, 0, result.stderr) |
| 100 | + assert.doesNotMatch(result.stdout, /TOP-SECRET-CONTENT/) |
| 101 | + }) |
| 102 | + |
| 103 | + it("survives a malformed ui_messages.json", () => { |
| 104 | + const taskDir = path.join(tmp, "tasks", "broken") |
| 105 | + fs.mkdirSync(taskDir, { recursive: true }) |
| 106 | + fs.writeFileSync(path.join(taskDir, "ui_messages.json"), "{not json") |
| 107 | + |
| 108 | + assert.equal(analyze().status, 0) |
| 109 | + }) |
| 110 | +}) |
| 111 | + |
| 112 | +describe("mock-openai-server", () => { |
| 113 | + it("rejects an unsafe --dir before listening", () => { |
| 114 | + for (const bad of ['x"; touch /tmp/pwned; #', ".", "../x", "-rf"]) { |
| 115 | + const result = node("mock-openai-server.mjs", ["--dir", bad, "--port", "0"]) |
| 116 | + |
| 117 | + assert.equal(result.status, 1, bad) |
| 118 | + assert.match(result.stderr, /Invalid --dir/) |
| 119 | + } |
| 120 | + }) |
| 121 | + |
| 122 | + describe("HTTP", () => { |
| 123 | + let child |
| 124 | + let base |
| 125 | + |
| 126 | + beforeEach(async () => { |
| 127 | + const port = await new Promise((resolve) => { |
| 128 | + const probe = net.createServer().listen(0, "127.0.0.1", () => { |
| 129 | + const { port } = probe.address() |
| 130 | + probe.close(() => resolve(port)) |
| 131 | + }) |
| 132 | + }) |
| 133 | + base = `http://127.0.0.1:${port}` |
| 134 | + child = spawn( |
| 135 | + process.execPath, |
| 136 | + [script("mock-openai-server.mjs"), "--scenario", "rapid", "--port", String(port), "--max-requests", "1"], |
| 137 | + { stdio: ["ignore", "pipe", "inherit"] }, |
| 138 | + ) |
| 139 | + await new Promise((resolve, reject) => { |
| 140 | + child.once("error", reject) |
| 141 | + child.stdout.on("data", (chunk) => String(chunk).includes("listening") && resolve()) |
| 142 | + }) |
| 143 | + }) |
| 144 | + |
| 145 | + afterEach(() => child.kill()) |
| 146 | + |
| 147 | + const complete = (body) => |
| 148 | + fetch(`${base}/v1/chat/completions`, { |
| 149 | + method: "POST", |
| 150 | + headers: { "content-type": "application/json" }, |
| 151 | + body: JSON.stringify(body), |
| 152 | + }) |
| 153 | + |
| 154 | + it("lists the mock model and 404s elsewhere", async () => { |
| 155 | + const models = await (await fetch(`${base}/v1/models`)).json() |
| 156 | + assert.deepEqual( |
| 157 | + models.data.map((m) => m.id), |
| 158 | + ["mock"], |
| 159 | + ) |
| 160 | + assert.equal((await fetch(`${base}/nope`)).status, 404) |
| 161 | + }) |
| 162 | + |
| 163 | + it("answers a request without tools with plain JSON", async () => { |
| 164 | + const body = await (await complete({ messages: [{ role: "user", content: "hi" }] })).json() |
| 165 | + |
| 166 | + assert.equal(body.object, "chat.completion") |
| 167 | + assert.match(body.choices[0].message.content, /Summary/) |
| 168 | + }) |
| 169 | + |
| 170 | + it("streams a scripted tool call, then attempt_completion once --max-requests is exceeded", async () => { |
| 171 | + const request = { stream: true, messages: [{ role: "user", content: "go" }], tools: [{ type: "function", function: { name: "x" } }] } |
| 172 | + |
| 173 | + const first = await (await complete(request)).text() |
| 174 | + assert.match(first, /"tool_calls"/) |
| 175 | + assert.ok(first.trimEnd().endsWith("data: [DONE]")) |
| 176 | + assert.doesNotMatch(first, /attempt_completion/) |
| 177 | + |
| 178 | + const second = await (await complete(request)).text() |
| 179 | + assert.match(second, /attempt_completion/) |
| 180 | + assert.ok(second.trimEnd().endsWith("data: [DONE]")) |
| 181 | + }) |
| 182 | + }) |
| 183 | +}) |
0 commit comments