返回 DeepSeek-Reasonix
attachment-native-smoke.mjs
根目录 / desktop / packaging / attachment-native-smoke.mjs
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
252 lines Plain Text