diff --git a/apps/api/src/memo-routes.test.mjs b/apps/api/src/memo-routes.test.mjs index ace48246..8e21f82e 100644 --- a/apps/api/src/memo-routes.test.mjs +++ b/apps/api/src/memo-routes.test.mjs @@ -158,6 +158,34 @@ describe("memo route contracts", () => { expect(await response.json()).toMatchObject({ memo: { id: "memo_created" } }); }); + test("stores the line breaks of a multipart image note as LF", async () => { + let contentMarkdown; + const dependencies = createDependencies({ + createMemo: async (_db, _workspaceId, input) => { + contentMarkdown = input.contentMarkdown; + return { id: "memo_created" }; + }, + }); + // Browsers encode line breaks in multipart text fields as CRLF. + const boundary = "edgeever-boundary"; + const part = (name, value) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`; + const body = [ + part("notebookId", "nb_1"), + part("contentMarkdown", "![clip](/api/v1/resources/EDGEVERRESOURCEID/blob)\r\n\r\nSource: https://example.com\r\nLine"), + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="clip.png"\r\nContent-Type: image/png\r\n\r\n\x89PNG\r\n`, + `--${boundary}--\r\n`, + ].join(""); + const response = await createApp(dependencies).request("/api/v1/memos/with-image", { + method: "POST", + headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` }, + body, + }, environment); + + expect(response.status).toBe(201); + const { resourceId } = await response.json(); + expect(contentMarkdown).toBe(`![clip](/api/v1/resources/${resourceId}/blob)\n\nSource: https://example.com\nLine`); + }); + test("enforces read scope before loading a memo", async () => { let called = false; const dependencies = createDependencies({ diff --git a/apps/api/src/memo-routes.ts b/apps/api/src/memo-routes.ts index fdd8bb6e..1e34487b 100644 --- a/apps/api/src/memo-routes.ts +++ b/apps/api/src/memo-routes.ts @@ -172,7 +172,11 @@ export const registerMemoRoutes = ( const file = form.get("file"); const notebookId = typeof form.get("notebookId") === "string" ? String(form.get("notebookId")).trim() : ""; const title = typeof form.get("title") === "string" ? String(form.get("title")) : ""; - const contentMarkdown = typeof form.get("contentMarkdown") === "string" ? String(form.get("contentMarkdown")) : ""; + // multipart/form-data encodes line breaks in text fields as CRLF (browsers + // always, and newer Bun versions too), while notes store LF line breaks. + const contentMarkdown = typeof form.get("contentMarkdown") === "string" + ? String(form.get("contentMarkdown")).replace(/\r\n?/g, "\n") + : ""; let tags: string[] = []; const tagsValue = form.get("tags"); if (typeof tagsValue === "string" && tagsValue.trim()) { diff --git a/apps/desktop/src/main/acp-host.test.mjs b/apps/desktop/src/main/acp-host.test.mjs index 34439124..877b5a00 100644 --- a/apps/desktop/src/main/acp-host.test.mjs +++ b/apps/desktop/src/main/acp-host.test.mjs @@ -104,7 +104,10 @@ if (${workBuddyAccountApi ? "true" : "false"}) { if (${workBuddyUnreadable ? "true" : "false"}) authenticated = true; const restoredAccount = authenticated; const save = () => writeFileSync(reportPath, JSON.stringify(report)); -if (${workBuddyAccountApi ? "true" : "false"}) { +// Like WorkBuddy, serve the account API only when started with --serve, which +// sets SERVER__PORT. Probes and chats start without it, and listening on port +// NaN is an error in Node and newer Bun versions. +if (${workBuddyAccountApi ? "true" : "false"} && process.env.SERVER__PORT) { const { createServer } = await import("node:http"); const server = createServer(async (req, res) => { if (req.headers.authorization !== "Bearer " + process.env.CODEBUDDY_GATEWAY_PASSWORD) { @@ -348,7 +351,13 @@ describe("ACP command allow-list", () => { for (const name of ["claude-agent-acp", "openclaw", "hermes"]) { await writeFile(path.join(directory, name), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); } - const deps = { platform: "darwin", pathEnv: directory, home: directory }; + // Only the temporary directory exists, so commands installed on the + // machine running the tests (for example in /opt/homebrew/bin) are ignored. + const onlyTemporaryDirectory = (candidate) => { + if (!candidate.startsWith(directory)) throw Object.assign(new Error(`ENOENT: ${candidate}`), { code: "ENOENT" }); + return realpathSync(candidate); + }; + const deps = { platform: "darwin", pathEnv: directory, home: directory, realpathSync: onlyTemporaryDirectory }; expect(resolveAcpCommand({ id: "claudeCode" }, deps).command).toMatchObject({ command: realpathSync(path.join(directory, "claude-agent-acp")), args: [] }); expect(resolveAcpCommand({ id: "openClaw" }, deps).command).toMatchObject({ command: realpathSync(path.join(directory, "openclaw")), args: ["acp"] }); expect(resolveAcpCommand({ id: "hermesAgent" }, deps).command).toMatchObject({ command: realpathSync(path.join(directory, "hermes")), args: ["acp"] });