-
Notifications
You must be signed in to change notification settings - Fork 300
fix(webview): omit originalContent from webview messages and fetch on demand #1888
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
daewoongoh
wants to merge
3
commits into
Zoo-Code-Org:main
Choose a base branch
from
daewoongoh:fix/webview-omit-original-content
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
71c8fb6
fix(webview): omit originalContent from webview messages and fetch on…
daewoongoh ae5662a
fix: address review on original-content fetch and gray-screen tooling
daewoongoh 43159fe
fix(scripts): validate --build-mode and add tests for the gray-screen…
daewoongoh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import assert from "node:assert/strict" | ||
| import fs from "node:fs" | ||
| import os from "node:os" | ||
| import path from "node:path" | ||
| import { afterEach, beforeEach, describe, it } from "node:test" | ||
|
|
||
| import { | ||
| integerFlag, | ||
| parseFlagArgs, | ||
| resolveBuildDir, | ||
| resolveServedFile, | ||
| validateRelativeDir, | ||
| writeTaskAtomically, | ||
| } from "../lib.mjs" | ||
|
|
||
| let tmp | ||
|
|
||
| beforeEach(() => { | ||
| tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gray-screen-test-")) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| fs.rmSync(tmp, { recursive: true, force: true }) | ||
| }) | ||
|
|
||
| describe("parseFlagArgs / integerFlag", () => { | ||
| it("parses flag/value pairs", () => { | ||
| assert.deepEqual(parseFlagArgs(["--messages", "10", "--two-byte", "true"]), { messages: "10", "two-byte": "true" }) | ||
| }) | ||
|
|
||
| it("rejects a flag with a missing operand", () => { | ||
| assert.throws(() => parseFlagArgs(["--messages", "--text-bytes", "5"]), /Missing value for --messages/) | ||
| assert.throws(() => parseFlagArgs(["--messages"]), /Missing value for --messages/) | ||
| }) | ||
|
|
||
| it("accepts integers at or above the minimum and rejects everything else", () => { | ||
| assert.equal(integerFlag("messages", "5000", 1), 5000) | ||
| assert.equal(integerFlag("image-every", "0", 0), 0) | ||
| for (const bad of ["0", "-3", "1.5", "abc", "", "NaN"]) { | ||
| assert.throws(() => integerFlag("messages", bad, 1), /--messages must be an integer >= 1/) | ||
| } | ||
| }) | ||
| }) | ||
|
|
||
| describe("validateRelativeDir", () => { | ||
| it("accepts plain relative paths", () => { | ||
| for (const ok of [".mock-session", "a", "src/mock_1", "a.b/c-d"]) assert.equal(validateRelativeDir(ok), ok) | ||
| }) | ||
|
|
||
| it("rejects shell metacharacters, absolute paths, traversal, dot and option-like segments", () => { | ||
| const bad = ['safe"; touch /tmp/pwned; #', "a b", "$(id)", "/abs", "../x", "a/../b", ".", "a/./b", "-rf", "a/-rf", "", "a//b"] | ||
| for (const dir of bad) assert.throws(() => validateRelativeDir(dir), /Invalid --dir/, JSON.stringify(dir)) | ||
| }) | ||
| }) | ||
|
|
||
| describe("resolveBuildDir", () => { | ||
| it("maps the documented modes to fixed directories under the temp root", () => { | ||
| assert.equal(resolveBuildDir("production", tmp), path.join(tmp, "zoo-webview-stress-build")) | ||
| assert.equal(resolveBuildDir("development", tmp), path.join(tmp, "zoo-webview-stress-build-development")) | ||
| }) | ||
|
|
||
| it("rejects any other mode, including path traversal", () => { | ||
| for (const mode of ["../../../tmp/victim", "staging", "", "production/../x"]) { | ||
| assert.throws(() => resolveBuildDir(mode, tmp), /Invalid --build-mode/, mode) | ||
| } | ||
| }) | ||
|
|
||
| it("refuses an output path that is a symlink", () => { | ||
| fs.mkdirSync(path.join(tmp, "elsewhere")) | ||
| fs.symlinkSync(path.join(tmp, "elsewhere"), path.join(tmp, "zoo-webview-stress-build")) | ||
|
|
||
| assert.throws(() => resolveBuildDir("production", tmp), /symlink/) | ||
| }) | ||
| }) | ||
|
|
||
| describe("resolveServedFile", () => { | ||
| beforeEach(() => { | ||
| fs.mkdirSync(path.join(tmp, "build", "assets"), { recursive: true }) | ||
| fs.mkdirSync(path.join(tmp, "build-secret")) | ||
| fs.writeFileSync(path.join(tmp, "build", "index.html"), "index") | ||
| fs.writeFileSync(path.join(tmp, "build", "assets", "app.js"), "js") | ||
| fs.writeFileSync(path.join(tmp, "build-secret", "credentials.json"), "secret") | ||
| fs.symlinkSync(path.join(tmp, "build-secret"), path.join(tmp, "build", "link")) | ||
| }) | ||
|
|
||
| const root = () => path.join(tmp, "build") | ||
|
|
||
| it("serves files inside the build directory, with / mapping to index.html", () => { | ||
| assert.equal(resolveServedFile(root(), "/"), fs.realpathSync(path.join(root(), "index.html"))) | ||
| assert.equal(resolveServedFile(root(), "/assets/app.js"), fs.realpathSync(path.join(root(), "assets", "app.js"))) | ||
| }) | ||
|
|
||
| it("returns undefined for traversal (plain, encoded and sibling-prefix), symlink escapes, directories and bad input", () => { | ||
| for (const p of [ | ||
| "/../build-secret/credentials.json", | ||
| "/%2e%2e%2fbuild-secret%2fcredentials.json", | ||
| "/link/credentials.json", | ||
| "/assets", | ||
| "/missing.js", | ||
| "/%E0%A4%A", | ||
| "/a\0b", | ||
| ]) { | ||
| assert.equal(resolveServedFile(root(), p), undefined, p) | ||
| } | ||
| }) | ||
|
|
||
| it("returns undefined when the build directory does not exist", () => { | ||
| assert.equal(resolveServedFile(path.join(tmp, "nope"), "/"), undefined) | ||
| }) | ||
| }) | ||
|
|
||
| describe("writeTaskAtomically", () => { | ||
| it("writes every file into tasks/<id> and leaves no staging directory", () => { | ||
| const taskDir = writeTaskAtomically(tmp, "t1", { "a.json": { a: 1 }, "b.json": [2] }) | ||
|
|
||
| assert.equal(taskDir, path.join(tmp, "tasks", "t1")) | ||
| assert.deepEqual(JSON.parse(fs.readFileSync(path.join(taskDir, "a.json"), "utf8")), { a: 1 }) | ||
| assert.deepEqual(JSON.parse(fs.readFileSync(path.join(taskDir, "b.json"), "utf8")), [2]) | ||
| assert.deepEqual(fs.readdirSync(tmp).sort(), ["tasks"]) | ||
| }) | ||
|
|
||
| it("leaves neither a task directory nor staging files when a write fails", () => { | ||
| const circular = {} | ||
| circular.self = circular | ||
|
|
||
| assert.throws(() => writeTaskAtomically(tmp, "t2", { "ok.json": { ok: true }, "bad.json": circular }), /circular/i) | ||
|
|
||
| assert.equal(fs.existsSync(path.join(tmp, "tasks", "t2")), false) | ||
| assert.deepEqual(fs.readdirSync(tmp), []) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| // Subprocess tests for the executable gray-screen tools. Run: node --test scripts/gray-screen/__tests__/tools.test.mjs | ||
| // The browser harnesses (webview-heap-matrix, webview-render-stress) need a built webview and Chromium, so only | ||
| // their pure parts (static serving, build directory) are covered, in lib.test.mjs. | ||
| import assert from "node:assert/strict" | ||
| import { spawn, spawnSync } from "node:child_process" | ||
| import fs from "node:fs" | ||
| import net from "node:net" | ||
| import os from "node:os" | ||
| import path from "node:path" | ||
| import { afterEach, beforeEach, describe, it } from "node:test" | ||
| import { fileURLToPath } from "node:url" | ||
|
|
||
| const dir = path.dirname(fileURLToPath(import.meta.url)) | ||
| const script = (name) => path.join(dir, "..", name) | ||
| const node = (name, args) => spawnSync(process.execPath, [script(name), ...args], { encoding: "utf8" }) | ||
|
|
||
| let tmp | ||
|
|
||
| beforeEach(() => { | ||
| tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gray-screen-tools-")) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| fs.rmSync(tmp, { recursive: true, force: true }) | ||
| }) | ||
|
|
||
| const generate = (...args) => node("generate-large-task.mjs", ["--storage", tmp, ...args]) | ||
|
|
||
| function readTask() { | ||
| const [id] = fs.readdirSync(path.join(tmp, "tasks")) | ||
| const read = (name) => JSON.parse(fs.readFileSync(path.join(tmp, "tasks", id, name), "utf8")) | ||
| return { id, messages: read("ui_messages.json"), api: read("api_conversation_history.json"), item: read("history_item.json") } | ||
| } | ||
|
|
||
| describe("generate-large-task", () => { | ||
| it("writes a complete task with the requested number of messages", () => { | ||
| const result = generate("--messages", "40", "--text-bytes", "100") | ||
|
|
||
| assert.equal(result.status, 0, result.stderr) | ||
| const { id, messages, item } = readTask() | ||
| assert.ok(messages.length >= 40) | ||
| assert.equal(item.id, id) | ||
| assert.match(item.task, /\[LOAD TEST\]/) | ||
| assert.deepEqual(fs.readdirSync(tmp).sort(), ["tasks"]) | ||
| }) | ||
|
|
||
| it("adds images when asked to", () => { | ||
| assert.equal(generate("--messages", "30", "--text-bytes", "100", "--image-every", "5", "--image-kb", "1").status, 0) | ||
|
|
||
| assert.ok(readTask().messages.some((m) => m.images?.length)) | ||
| }) | ||
|
|
||
| it("produces non-Latin-1 text with --two-byte true only", () => { | ||
| assert.equal(generate("--messages", "30", "--text-bytes", "100", "--two-byte", "true").status, 0) | ||
| assert.match(JSON.stringify(readTask().messages), /한글/) | ||
|
|
||
| fs.rmSync(path.join(tmp, "tasks"), { recursive: true }) | ||
| assert.equal(generate("--messages", "30", "--text-bytes", "100").status, 0) | ||
| assert.doesNotMatch(JSON.stringify(readTask().messages), /한글/) | ||
| }) | ||
|
|
||
| it("creates nothing when a flag has no value or is not a positive integer", () => { | ||
| for (const args of [["--messages", "--text-bytes", "5"], ["--messages", "abc"], ["--messages", "0"], ["--image-kb", "-1"]]) { | ||
| const result = generate(...args) | ||
|
|
||
| assert.notEqual(result.status, 0, args.join(" ")) | ||
| assert.deepEqual(fs.readdirSync(tmp), [], args.join(" ")) | ||
| } | ||
| }) | ||
| }) | ||
|
|
||
| describe("analyze-session", () => { | ||
| const analyze = (...args) => node("analyze-session.mjs", ["--storage", tmp, ...args]) | ||
|
|
||
| it("excludes generated [LOAD TEST] tasks unless asked, and reports sizes for the rest", () => { | ||
| assert.equal(generate("--messages", "40", "--text-bytes", "100").status, 0) | ||
|
|
||
| const excluded = analyze() | ||
| assert.equal(excluded.status, 0, excluded.stderr) | ||
| assert.match(excluded.stdout, /excluded 1 generated/) | ||
| assert.match(excluded.stdout, /analyzed set: 0/) | ||
|
|
||
| const included = analyze("--include-generated", "true") | ||
| assert.equal(included.status, 0, included.stderr) | ||
| assert.match(included.stdout, /analyzed set: 1/) | ||
| assert.match(included.stdout, /size\(MB\)\s+msgs\s+toolTxt/) | ||
| }) | ||
|
|
||
| it("does not print message content", () => { | ||
| const taskDir = path.join(tmp, "tasks", "real-task") | ||
| fs.mkdirSync(taskDir, { recursive: true }) | ||
| fs.writeFileSync( | ||
| path.join(taskDir, "ui_messages.json"), | ||
| JSON.stringify([{ ts: 1, type: "say", say: "text", text: "TOP-SECRET-CONTENT" }]), | ||
| ) | ||
|
|
||
| const result = analyze() | ||
|
|
||
| assert.equal(result.status, 0, result.stderr) | ||
| assert.doesNotMatch(result.stdout, /TOP-SECRET-CONTENT/) | ||
| }) | ||
|
|
||
| it("survives a malformed ui_messages.json", () => { | ||
| const taskDir = path.join(tmp, "tasks", "broken") | ||
| fs.mkdirSync(taskDir, { recursive: true }) | ||
| fs.writeFileSync(path.join(taskDir, "ui_messages.json"), "{not json") | ||
|
|
||
| assert.equal(analyze().status, 0) | ||
| }) | ||
| }) | ||
|
|
||
| describe("mock-openai-server", () => { | ||
| it("rejects an unsafe --dir before listening", () => { | ||
| for (const bad of ['x"; touch /tmp/pwned; #', ".", "../x", "-rf"]) { | ||
| const result = node("mock-openai-server.mjs", ["--dir", bad, "--port", "0"]) | ||
|
|
||
| assert.equal(result.status, 1, bad) | ||
| assert.match(result.stderr, /Invalid --dir/) | ||
| } | ||
| }) | ||
|
|
||
| describe("HTTP", () => { | ||
| let child | ||
| let base | ||
|
|
||
| beforeEach(async () => { | ||
| const port = await new Promise((resolve) => { | ||
| const probe = net.createServer().listen(0, "127.0.0.1", () => { | ||
| const { port } = probe.address() | ||
| probe.close(() => resolve(port)) | ||
| }) | ||
| }) | ||
| base = `http://127.0.0.1:${port}` | ||
| child = spawn( | ||
| process.execPath, | ||
| [script("mock-openai-server.mjs"), "--scenario", "rapid", "--port", String(port), "--max-requests", "1"], | ||
| { stdio: ["ignore", "pipe", "inherit"] }, | ||
| ) | ||
| await new Promise((resolve, reject) => { | ||
| child.once("error", reject) | ||
| child.stdout.on("data", (chunk) => String(chunk).includes("listening") && resolve()) | ||
| }) | ||
| }) | ||
|
|
||
| afterEach(() => child.kill()) | ||
|
|
||
| const complete = (body) => | ||
| fetch(`${base}/v1/chat/completions`, { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify(body), | ||
| }) | ||
|
|
||
| it("lists the mock model and 404s elsewhere", async () => { | ||
| const models = await (await fetch(`${base}/v1/models`)).json() | ||
| assert.deepEqual( | ||
| models.data.map((m) => m.id), | ||
| ["mock"], | ||
| ) | ||
| assert.equal((await fetch(`${base}/nope`)).status, 404) | ||
| }) | ||
|
|
||
| it("answers a request without tools with plain JSON", async () => { | ||
| const body = await (await complete({ messages: [{ role: "user", content: "hi" }] })).json() | ||
|
|
||
| assert.equal(body.object, "chat.completion") | ||
| assert.match(body.choices[0].message.content, /Summary/) | ||
| }) | ||
|
|
||
| it("streams a scripted tool call, then attempt_completion once --max-requests is exceeded", async () => { | ||
| const request = { stream: true, messages: [{ role: "user", content: "go" }], tools: [{ type: "function", function: { name: "x" } }] } | ||
|
|
||
| const first = await (await complete(request)).text() | ||
| assert.match(first, /"tool_calls"/) | ||
| assert.ok(first.trimEnd().endsWith("data: [DONE]")) | ||
| assert.doesNotMatch(first, /attempt_completion/) | ||
|
|
||
| const second = await (await complete(request)).text() | ||
| assert.match(second, /attempt_completion/) | ||
| assert.ok(second.trimEnd().endsWith("data: [DONE]")) | ||
| }) | ||
| }) | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Reject the readiness promise when the server exits.
The port probe releases its port before the child binds it. If another process takes that port, the child can exit before printing
listening; this promise handles neitherexitnor a startup timeout, so the HTTP tests cannot complete normally. Reject on earlyexitand bound the readiness wait. Node.js emitsexitwhen a spawned child ends; a successful spawn does not turn that exit into a spawnerror. (nodejs.org) As per path instructions, “Check cleanup and deterministic async behavior.”🤖 Prompt for AI Agents
Source: Path instructions