| 1 | // Qualify an assembled app with a loopback provider and disposable data home. |
| 2 | // Usage: node desktop/packaging/attachment-native-smoke.mjs <Reasonix.app> <evidence-dir> |
| 3 | import assert from "node:assert/strict"; |
| 4 | import { createHash } from "node:crypto"; |
| 5 | import { createServer } from "node:http"; |
| 6 | import { createRequire } from "node:module"; |
| 7 | import { execFile } from "node:child_process"; |
| 8 | import { promisify } from "node:util"; |
| 9 | import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, globSync, unlinkSync, cpSync } from "node:fs"; |
| 10 | import { tmpdir } from "node:os"; |
| 11 | import { join, resolve } from "node:path"; |
| 12 | import { packagedSmokeEnv } from "./smoke-env.mjs"; |
| 13 | import { waitForSmokeCondition } from "./smoke-poll.mjs"; |
| 14 | |
| 15 | const require = createRequire(new URL("../electron/package.json", import.meta.url)); |
| 16 | const { _electron } = require("playwright"); |
| 17 | const bundle = resolve(process.argv[2]); |
| 18 | const evidence = resolve(process.argv[3]); |
| 19 | const home = mkdtempSync(join(tmpdir(), "reasonix-attachment-native-")); |
| 20 | const project = join(home, "project"); |
| 21 | mkdirSync(evidence, { recursive: true }); |
| 22 | mkdirSync(project, { recursive: true }); |
| 23 | const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; |
| 24 | const dataURL = `data:image/png;base64,${png}`; |
| 25 | const digest = createHash("sha256").update(Buffer.from(png, "base64")).digest("hex"); |
| 26 | const requests = []; |
| 27 | const checks = []; |
| 28 | const held = new Map(); |
| 29 | let app, page, compatibilityRef; |
| 30 | const provider = createServer(async (req, res) => { |
| 31 | let body = ""; |
| 32 | for await (const part of req) body += part; |
| 33 | const payload = JSON.parse(body || "{}"); |
| 34 | requests.push(payload); |
| 35 | const messages = payload.messages || []; |
| 36 | const lastUser = [...messages].reverse().find(message => message.role === "user"); |
| 37 | if (JSON.stringify(lastUser?.content).includes("CANCEL_IMAGE_REQUEST")) { |
| 38 | const state = { closed: false }; |
| 39 | held.set("cancel", state); |
| 40 | res.on("close", () => { state.closed = true; }); |
| 41 | res.writeHead(200, { "Content-Type": "text/event-stream" }); |
| 42 | res.write(": waiting for cancellation\n\n"); |
| 43 | return; |
| 44 | } |
| 45 | const toolDone = messages.some(message => message.role === "tool"); |
| 46 | const readTool = JSON.stringify(lastUser?.content).includes("READ_IMAGE") && !toolDone; |
| 47 | const delta = readTool ? { tool_calls: [{ index: 0, id: "image-tool", type: "function", function: { name: "view_image", arguments: JSON.stringify({ path: join(project, "tool.png") }) } }] } |
| 48 | : { content: "ATTACHMENT_FIXTURE_OK" }; |
| 49 | res.writeHead(200, { "Content-Type": "text/event-stream" }); |
| 50 | res.end(`data: ${JSON.stringify({ id: "attachment-fixture", choices: [{ index: 0, delta, finish_reason: null }] })}\n\n` |
| 51 | + `data: ${JSON.stringify({ id: "attachment-fixture", choices: [{ index: 0, delta: {}, finish_reason: readTool ? "tool_calls" : "stop" }] })}\n\ndata: [DONE]\n\n`); |
| 52 | }); |
| 53 | await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve)); |
| 54 | writeFileSync(join(home, "config.toml"), `default_model = "fixture/vision"\n[desktop]\nprovider_access = ["fixture"]\n[[providers]]\nname = "fixture"\nkind = "openai"\nbase_url = "http://127.0.0.1:${provider.address().port}/v1"\nmodels = ["vision", "vision-alt"]\nvision_models = ["vision", "vision-alt"]\ndefault = "vision"\napi_key_env = "ATTACHMENT_FIXTURE_KEY"\n`); |
| 55 | writeFileSync(join(project, "tool.png"), Buffer.from(png, "base64")); |
| 56 | const invoke = (method, args = []) => page.evaluate(({ method, args }) => window.reasonixDesktop.invoke(method, args), { method, args }); |
| 57 | const active = async () => (await invoke("ListTabs")).find(tab => tab.active); |
| 58 | const settle = () => waitForSmokeCondition(async () => (await invoke("ListTabs")).every(tab => !tab.running)); |
| 59 | const composerTarget = tab => ({ |
| 60 | kind: "session", |
| 61 | tabId: tab.id, |
| 62 | session: tab.session ?? null, |
| 63 | generation: tab.sessionGeneration ?? 0, |
| 64 | }); |
| 65 | const record = text => { checks.push(text); console.log(`PASS ${text}`); }; |
| 66 | function imageURLs(value, out = []) { |
| 67 | if (!value || typeof value !== "object") return out; |
| 68 | if (value.type === "image_url") out.push(value.image_url.url); |
| 69 | for (const child of Object.values(value)) { |
| 70 | if (Array.isArray(child)) child.forEach(item => imageURLs(item, out)); |
| 71 | else if (child && typeof child === "object") imageURLs(child, out); |
| 72 | } |
| 73 | return out; |
| 74 | } |
| 75 | async function newProject(mode) { |
| 76 | const tab = await invoke("EnsureBlankSurface", ["project", project]); |
| 77 | await invoke("SetActiveTab", [tab.id]); |
| 78 | await invoke("SetToolApprovalModeForTab", [tab.id, mode]); |
| 79 | return tab; |
| 80 | } |
| 81 | async function submitImage(tab, label) { |
| 82 | const target = await invoke("CaptureAttachmentTarget", [composerTarget(tab)]); |
| 83 | try { |
| 84 | const draft = await invoke("StageImageForTarget", [target.token, label, "pixel.png", "image/png", dataURL]); |
| 85 | assert.equal(await invoke("ReadDraftImageForTarget", [target.token, draft.draftId]), dataURL); |
| 86 | const before = requests.length; |
| 87 | const request = { input: label, display: label, attachments: [{ clientAttachmentId: label, draftId: draft.draftId }] }; |
| 88 | const receipt = await invoke("StartTurnForAttachmentTarget", [target.token, label, request]); |
| 89 | await waitForSmokeCondition(() => requests.length > before); |
| 90 | await settle(); |
| 91 | assert.ok(requests.slice(before).some(request => imageURLs(request).includes(dataURL))); |
| 92 | const historyImage = await invoke("ReadSessionAttachmentForTab", [tab.id, digest, 0]); |
| 93 | assert.equal(historyImage.data, png); |
| 94 | assert.equal(historyImage.done, true); |
| 95 | const retry = await invoke("StartTurnForAttachmentTarget", [target.token, label, request]); |
| 96 | assert.equal(retry.turnId, receipt.turnId); |
| 97 | return { target, draft, request }; |
| 98 | } finally { await invoke("ReleaseAttachmentTarget", [target.token]); } |
| 99 | } |
| 100 | try { |
| 101 | app = await _electron.launch({ executablePath: join(bundle, "Contents/MacOS/Reasonix"), env: { ...packagedSmokeEnv(process.env, home), ATTACHMENT_FIXTURE_KEY: "loopback-only" }, timeout: 60_000 }); |
| 102 | const manifest = JSON.parse(readFileSync(join(bundle, "Contents/Resources/build.json"), "utf8")); |
| 103 | await waitForSmokeCondition(async () => { |
| 104 | for (const candidate of app.windows()) { |
| 105 | try { |
| 106 | if (await candidate.evaluate(() => Boolean(window.reasonixDesktop))) { |
| 107 | const version = await candidate.evaluate(() => window.reasonixDesktop.invoke("Version", [])); |
| 108 | if (version === manifest.version) { page = candidate; return true; } |
| 109 | } |
| 110 | } catch { /* The splash window can be replaced during the handshake. */ } |
| 111 | } |
| 112 | return false; |
| 113 | }, { timeout: 60_000 }); |
| 114 | assert.equal(await app.evaluate(({ app }) => app.isPackaged), true); |
| 115 | await page.locator("textarea").first().waitFor({ state: "visible", timeout: 60_000 }); |
| 116 | record("packaged renderer and service share the stamped version"); |
| 117 | for (const mode of ["workspace-write", "danger-full-access"]) { |
| 118 | const tab = await newProject(mode); |
| 119 | await submitImage(tab, `AUTO_${mode}`); |
| 120 | compatibilityRef ??= (await active()).session; |
| 121 | record(`${mode}: automatic image request contains exact admitted bytes; retry reuses receipt`); |
| 122 | const toolTab = await newProject(mode); |
| 123 | const before = requests.length; |
| 124 | await invoke("SubmitToTabWithID", [toolTab.id, "READ_IMAGE", `TOOL_${mode}`]); |
| 125 | await waitForSmokeCondition(() => requests.length >= before + 2); |
| 126 | await settle(); |
| 127 | assert.ok(requests.slice(before).some(request => imageURLs(request).includes(dataURL))); |
| 128 | record(`${mode}: view_image returns the same bytes without elevation`); |
| 129 | } |
| 130 | const cancelTab = await newProject("workspace-write"); |
| 131 | const cancelTarget = await invoke("CaptureAttachmentTarget", [composerTarget(cancelTab)]); |
| 132 | const cancelDraft = await invoke("StageImageForTarget", [cancelTarget.token, "cancel-image", "cancel.png", "image/png", dataURL]); |
| 133 | await invoke("StartTurnForAttachmentTarget", [cancelTarget.token, "cancel-turn", { input: "CANCEL_IMAGE_REQUEST", attachments: [{ clientAttachmentId: "cancel", draftId: cancelDraft.draftId }] }]); |
| 134 | await waitForSmokeCondition(() => held.has("cancel")); |
| 135 | await invoke("SetToolApprovalModeForTab", [cancelTab.id, "danger-full-access"]); |
| 136 | await waitForSmokeCondition(() => held.get("cancel").closed); |
| 137 | await settle(); |
| 138 | await invoke("ReleaseAttachmentTarget", [cancelTarget.token]); |
| 139 | await submitImage(cancelTab, "RETRY_AFTER_PERMISSION_CANCEL"); |
| 140 | record("permission change cancels the in-flight image request; a new send succeeds"); |
| 141 | const beforeRebuild = requests.length; |
| 142 | const rebuildTarget = await invoke("CaptureAttachmentTarget", [composerTarget(cancelTab)]); |
| 143 | const rebuildDraft = await invoke("StageImageForTarget", [rebuildTarget.token, "rebuild", "rebuild.png", "image/png", dataURL]); |
| 144 | await invoke("SetModelForTab", [cancelTab.id, "fixture/vision-alt"]); |
| 145 | await assert.rejects(invoke("ReadDraftImageForTarget", [rebuildTarget.token, rebuildDraft.draftId])); |
| 146 | const reboundTarget = await invoke("CaptureAttachmentTarget", [composerTarget(await active())]); |
| 147 | const reboundDraft = await invoke("RebindDraftImageForTarget", [reboundTarget.token, rebuildDraft.draftId]); |
| 148 | assert.notEqual(reboundDraft.draftId, rebuildDraft.draftId); |
| 149 | assert.equal(requests.length, beforeRebuild); |
| 150 | await invoke("StartTurnForAttachmentTarget", [reboundTarget.token, "rebound-turn", { input: "AFTER_RUNTIME_REBUILD", attachments: [{ clientAttachmentId: "rebuild", draftId: rebuildDraft.draftId }] }]); |
| 151 | await waitForSmokeCondition(() => requests.length > beforeRebuild); |
| 152 | await settle(); |
| 153 | assert.ok(requests.slice(beforeRebuild).some(request => imageURLs(request).includes(dataURL))); |
| 154 | await invoke("ReleaseAttachmentTarget", [rebuildTarget.token]); |
| 155 | await invoke("ReleaseAttachmentTarget", [reboundTarget.token]); |
| 156 | record("runtime rebuild renews a same-session draft without auto-send; user retry delivers original bytes"); |
| 157 | mkdirSync(join(project, ".reasonix/attachments"), { recursive: true }); |
| 158 | writeFileSync(join(project, ".reasonix/attachments/cli.png"), Buffer.from(png, "base64")); |
| 159 | const beforeCLI = requests.length; |
| 160 | const cli = await promisify(execFile)(join(bundle, "Contents/Resources/service/reasonix"), ["run", "--dir", project, "--print", "inspect @.reasonix/attachments/cli.png"], { |
| 161 | env: { ...packagedSmokeEnv(process.env, home), ATTACHMENT_FIXTURE_KEY: "loopback-only" }, timeout: 60_000, |
| 162 | }); |
| 163 | assert.match(cli.stdout, /ATTACHMENT_FIXTURE_OK/); |
| 164 | assert.ok(requests.slice(beforeCLI).some(request => imageURLs(request).includes(dataURL))); |
| 165 | record("bundled CLI sends exact legacy workspace attachment bytes to the provider"); |
| 166 | await invoke("EnsureBlankSurface", ["global", ""]); |
| 167 | const global = await active(); |
| 168 | await invoke("SubmitToTabWithID", [global.id, "PIN_GLOBAL_SESSION", "pin-global"]); |
| 169 | await settle(); |
| 170 | const target = await invoke("CaptureAttachmentTarget", [composerTarget(global)]); |
| 171 | const other = await invoke("EnsureBlankTab", ["project", project]); |
| 172 | await invoke("SetActiveTab", [other.id]); |
| 173 | const draft = await invoke("StageImageForTarget", [target.token, "focus", "focus.png", "image/png", dataURL]); |
| 174 | assert.equal(await invoke("ReadDraftImageForTarget", [target.token, draft.draftId]), dataURL); |
| 175 | const otherTarget = await invoke("CaptureAttachmentTarget", [composerTarget(other)]); |
| 176 | await assert.rejects(invoke("ReadDraftImageForTarget", [otherTarget.token, draft.draftId])); |
| 177 | await invoke("ReleaseAttachmentTarget", [target.token]); |
| 178 | await invoke("ReleaseAttachmentTarget", [otherTarget.token]); |
| 179 | record("Global-to-project switch preserves target ownership and rejects cross-session draft reads"); |
| 180 | |
| 181 | // RPC navigation above deliberately bypasses the renderer navigation owner. |
| 182 | // Reload to hydrate that owner before exercising the real Composer UI. |
| 183 | await page.reload(); |
| 184 | await page.locator("textarea").first().waitFor({ state: "visible" }); |
| 185 | await waitForSmokeCondition(async () => !(await page.locator("textarea").first().isDisabled())); |
| 186 | const textarea = page.locator("textarea").first(); |
| 187 | await textarea.fill("RETAIN_FAILED_DRAFT"); |
| 188 | await textarea.evaluate((node, png) => { |
| 189 | const bytes = Uint8Array.from(atob(png), char => char.charCodeAt(0)); |
| 190 | const data = new DataTransfer(); |
| 191 | data.items.add(new File([bytes], "retained.png", { type: "image/png" })); |
| 192 | node.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data })); |
| 193 | }, png); |
| 194 | await page.locator(".composer-context__item").first().waitFor(); |
| 195 | const objects = globSync(`**/.content-v1/objects/**/${digest}`, { cwd: home }); |
| 196 | const workspaceSources = globSync("**/.reasonix/attachments/*", { cwd: home }).filter(path => { |
| 197 | const bytes = readFileSync(join(home, path)); |
| 198 | return createHash("sha256").update(bytes).digest("hex") === digest; |
| 199 | }); |
| 200 | const admittedFiles = [...objects, ...workspaceSources]; |
| 201 | assert.ok(admittedFiles.length > 0); |
| 202 | for (const path of admittedFiles) unlinkSync(join(home, path)); |
| 203 | const before = requests.length; |
| 204 | await page.locator(".composer__btn--send").click(); |
| 205 | await page.waitForFunction(() => /图片读取失败|could not be read/i.test(document.body.textContent || "")); |
| 206 | await waitForSmokeCondition(async () => !(await page.locator(".composer__btn--send").isDisabled())); |
| 207 | assert.equal((await page.locator("body").innerText()).includes(home), false); |
| 208 | assert.equal(await textarea.inputValue(), "RETAIN_FAILED_DRAFT"); |
| 209 | assert.equal(await page.locator(".composer-context__item").count(), 1); |
| 210 | assert.equal(requests.length, before); |
| 211 | await page.screenshot({ path: join(evidence, "failed-draft-retained.png") }); |
| 212 | record("deleted content blocks provider calls and retains the actual Composer text and image"); |
| 213 | for (const path of admittedFiles) writeFileSync(join(home, path), Buffer.from(png, "base64")); |
| 214 | if (process.argv[4]) { |
| 215 | assert.ok(compatibilityRef?.sessionId); |
| 216 | await app.close(); |
| 217 | app = null; |
| 218 | const oldHome = mkdtempSync(join(tmpdir(), "reasonix-attachment-downgrade-")); |
| 219 | cpSync(home, oldHome, { recursive: true }); |
| 220 | const sessionDir = join(oldHome, "desktop-sessions-v5/by-id", compatibilityRef.sessionId); |
| 221 | const protectedFiles = globSync("**/*", { cwd: sessionDir }).filter(path => /manifest\.json$|events\.frames$/.test(path)); |
| 222 | const beforeFiles = protectedFiles.map(path => [path, readFileSync(join(sessionDir, path))]); |
| 223 | assert.ok(beforeFiles.length >= 2); |
| 224 | const previous = resolve(process.argv[4]); |
| 225 | app = await _electron.launch({ executablePath: join(previous, "Contents/MacOS/Reasonix"), env: { ...packagedSmokeEnv(process.env, oldHome), ATTACHMENT_FIXTURE_KEY: "loopback-only" }, timeout: 60_000 }); |
| 226 | await waitForSmokeCondition(async () => { |
| 227 | for (const candidate of app.windows()) { |
| 228 | try { |
| 229 | if (await candidate.evaluate(() => window.reasonixDesktop?.invoke("Version", []))) { page = candidate; return true; } |
| 230 | } catch { /* Wait for the production service. */ } |
| 231 | } |
| 232 | return false; |
| 233 | }, { timeout: 60_000 }); |
| 234 | const beforeRequests = requests.length; |
| 235 | await assert.rejects(invoke("OpenSession", [compatibilityRef]), /revision|unsupported|版本|格式/i); |
| 236 | for (const [path, bytes] of beforeFiles) assert.deepEqual(readFileSync(join(sessionDir, path)), bytes); |
| 237 | assert.equal(requests.length, beforeRequests); |
| 238 | record("previous installed binary rejects revision 3 and preserves manifest/event bytes"); |
| 239 | } |
| 240 | writeFileSync(join(evidence, "result.json"), JSON.stringify({ manifest, home, checks, imageDigest: digest, providerRequests: requests.length }, null, 2)); |
| 241 | } catch (error) { |
| 242 | if (page && !page.isClosed()) { |
| 243 | await page.screenshot({ path: join(evidence, "failure.png") }); |
| 244 | writeFileSync(join(evidence, "failure-dom.txt"), await page.locator("body").innerText()); |
| 245 | } |
| 246 | writeFileSync(join(evidence, "failure.json"), JSON.stringify({ home, checks, error: String(error), stack: error.stack }, null, 2)); |
| 247 | throw error; |
| 248 | } finally { |
| 249 | if (app) await app.close(); |
| 250 | await new Promise(resolve => provider.close(resolve)); |
| 251 | } |
| 252 |