diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift index c33aa12a3..93b2d53c6 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift @@ -331,7 +331,11 @@ actor MacNodeBridgeSession { let now = self.clock.now if now > last.advanced(by: timeout) { let age = last.duration(to: now) - self.logger.warning("Node bridge heartbeat timed out; disconnecting (age: \(String(describing: age), privacy: .public)).") + let ageDescription = String(describing: age) + let message = + "Node bridge heartbeat timed out; disconnecting " + + "(age: \(ageDescription, privacy: .public))." + self.logger.warning(message) await self.disconnect() return } @@ -341,7 +345,11 @@ actor MacNodeBridgeSession { do { try await self.send(BridgePing(type: "ping", id: id)) } catch { - self.logger.warning("Node bridge ping send failed; disconnecting (error: \(String(describing: error), privacy: .public)).") + let errorDescription = String(describing: error) + let message = + "Node bridge ping send failed; disconnecting " + + "(error: \(errorDescription, privacy: .public))." + self.logger.warning(message) await self.disconnect() return } @@ -356,7 +364,11 @@ actor MacNodeBridgeSession { private func handleConnectionState(_ state: NWConnection.State) async { switch state { case let .failed(error): - self.logger.warning("Node bridge connection failed; disconnecting (error: \(String(describing: error), privacy: .public)).") + let errorDescription = String(describing: error) + let message = + "Node bridge connection failed; disconnecting " + + "(error: \(errorDescription, privacy: .public))." + self.logger.warning(message) await self.disconnect() case .cancelled: self.logger.warning("Node bridge connection cancelled; disconnecting.") diff --git a/src/media/store.test.ts b/src/media/store.test.ts index 19021ce0c..8d8ed036b 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -2,122 +2,133 @@ import fs from "node:fs/promises"; import path from "node:path"; import JSZip from "jszip"; import sharp from "sharp"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -const realOs = await vi.importActual("node:os"); -const HOME = path.join(realOs.tmpdir(), "clawdbot-home-test"); - -vi.mock("node:os", () => ({ - default: { homedir: () => HOME, tmpdir: () => realOs.tmpdir() }, - homedir: () => HOME, - tmpdir: () => realOs.tmpdir(), -})); - -const store = await import("./store.js"); +import { withTempHome } from "../../test/helpers/temp-home.js"; describe("media store", () => { - beforeAll(async () => { - await fs.rm(HOME, { recursive: true, force: true }); - }); - - afterAll(async () => { - await fs.rm(HOME, { recursive: true, force: true }); - }); - it("creates and returns media directory", async () => { - const dir = await store.ensureMediaDir(); - const normalized = path.normalize(dir); - expect(normalized).toContain( - `${path.sep}.clawdbot${path.sep}media`, - ); - const stat = await fs.stat(dir); - expect(stat.isDirectory()).toBe(true); + await withTempHome(async () => { + vi.resetModules(); + const store = await import("./store.js"); + + const dir = await store.ensureMediaDir(); + const normalized = path.normalize(dir); + expect(normalized).toContain(`${path.sep}.clawdbot${path.sep}media`); + const stat = await fs.stat(dir); + expect(stat.isDirectory()).toBe(true); + }); }); it("saves buffers and enforces size limit", async () => { - const buf = Buffer.from("hello"); - const saved = await store.saveMediaBuffer(buf, "text/plain"); - const savedStat = await fs.stat(saved.path); - expect(savedStat.size).toBe(buf.length); - expect(saved.contentType).toBe("text/plain"); - expect(saved.path.endsWith(".txt")).toBe(true); + await withTempHome(async () => { + vi.resetModules(); + const store = await import("./store.js"); - const jpeg = await sharp({ - create: { width: 2, height: 2, channels: 3, background: "#123456" }, - }) - .jpeg({ quality: 80 }) - .toBuffer(); - const savedJpeg = await store.saveMediaBuffer(jpeg, "image/jpeg"); - expect(savedJpeg.contentType).toBe("image/jpeg"); - expect(savedJpeg.path.endsWith(".jpg")).toBe(true); + const buf = Buffer.from("hello"); + const saved = await store.saveMediaBuffer(buf, "text/plain"); + const savedStat = await fs.stat(saved.path); + expect(savedStat.size).toBe(buf.length); + expect(saved.contentType).toBe("text/plain"); + expect(saved.path.endsWith(".txt")).toBe(true); - const huge = Buffer.alloc(5 * 1024 * 1024 + 1); - await expect(store.saveMediaBuffer(huge)).rejects.toThrow( - "Media exceeds 5MB limit", - ); + const jpeg = await sharp({ + create: { width: 2, height: 2, channels: 3, background: "#123456" }, + }) + .jpeg({ quality: 80 }) + .toBuffer(); + const savedJpeg = await store.saveMediaBuffer(jpeg, "image/jpeg"); + expect(savedJpeg.contentType).toBe("image/jpeg"); + expect(savedJpeg.path.endsWith(".jpg")).toBe(true); + + const huge = Buffer.alloc(5 * 1024 * 1024 + 1); + await expect(store.saveMediaBuffer(huge)).rejects.toThrow( + "Media exceeds 5MB limit", + ); + }); }); it("copies local files and cleans old media", async () => { - const srcFile = path.join(HOME, "tmp-src.txt"); - await fs.mkdir(HOME, { recursive: true }); - await fs.writeFile(srcFile, "local file"); - const saved = await store.saveMediaSource(srcFile); - expect(saved.size).toBe(10); - const savedStat = await fs.stat(saved.path); - expect(savedStat.isFile()).toBe(true); - expect(path.extname(saved.path)).toBe(".txt"); + await withTempHome(async (home) => { + vi.resetModules(); + const store = await import("./store.js"); - // make the file look old and ensure cleanOldMedia removes it - const past = Date.now() - 10_000; - await fs.utimes(saved.path, past / 1000, past / 1000); - await store.cleanOldMedia(1); - await expect(fs.stat(saved.path)).rejects.toThrow(); + const srcFile = path.join(home, "tmp-src.txt"); + await fs.mkdir(home, { recursive: true }); + await fs.writeFile(srcFile, "local file"); + const saved = await store.saveMediaSource(srcFile); + expect(saved.size).toBe(10); + const savedStat = await fs.stat(saved.path); + expect(savedStat.isFile()).toBe(true); + expect(path.extname(saved.path)).toBe(".txt"); + + // make the file look old and ensure cleanOldMedia removes it + const past = Date.now() - 10_000; + await fs.utimes(saved.path, past / 1000, past / 1000); + await store.cleanOldMedia(1); + await expect(fs.stat(saved.path)).rejects.toThrow(); + }); }); it("sets correct mime for xlsx by extension", async () => { - const xlsxPath = path.join(HOME, "sheet.xlsx"); - await fs.mkdir(HOME, { recursive: true }); - await fs.writeFile(xlsxPath, "not really an xlsx"); + await withTempHome(async (home) => { + vi.resetModules(); + const store = await import("./store.js"); - const saved = await store.saveMediaSource(xlsxPath); - expect(saved.contentType).toBe( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ); - expect(path.extname(saved.path)).toBe(".xlsx"); + const xlsxPath = path.join(home, "sheet.xlsx"); + await fs.mkdir(home, { recursive: true }); + await fs.writeFile(xlsxPath, "not really an xlsx"); + + const saved = await store.saveMediaSource(xlsxPath); + expect(saved.contentType).toBe( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + expect(path.extname(saved.path)).toBe(".xlsx"); + }); }); it("renames media based on detected mime even when extension is wrong", async () => { - const pngBytes = await sharp({ - create: { width: 2, height: 2, channels: 3, background: "#00ff00" }, - }) - .png() - .toBuffer(); - const bogusExt = path.join(HOME, "image-wrong.bin"); - await fs.writeFile(bogusExt, pngBytes); + await withTempHome(async (home) => { + vi.resetModules(); + const store = await import("./store.js"); - const saved = await store.saveMediaSource(bogusExt); - expect(saved.contentType).toBe("image/png"); - expect(path.extname(saved.path)).toBe(".png"); + const pngBytes = await sharp({ + create: { width: 2, height: 2, channels: 3, background: "#00ff00" }, + }) + .png() + .toBuffer(); + const bogusExt = path.join(home, "image-wrong.bin"); + await fs.writeFile(bogusExt, pngBytes); - const buf = await fs.readFile(saved.path); - expect(buf.equals(pngBytes)).toBe(true); + const saved = await store.saveMediaSource(bogusExt); + expect(saved.contentType).toBe("image/png"); + expect(path.extname(saved.path)).toBe(".png"); + + const buf = await fs.readFile(saved.path); + expect(buf.equals(pngBytes)).toBe(true); + }); }); it("sniffs xlsx mime for zip buffers and renames extension", async () => { - const zip = new JSZip(); - zip.file( - "[Content_Types].xml", - '', - ); - zip.file("xl/workbook.xml", ""); - const fakeXlsx = await zip.generateAsync({ type: "nodebuffer" }); - const bogusExt = path.join(HOME, "sheet.bin"); - await fs.writeFile(bogusExt, fakeXlsx); + await withTempHome(async (home) => { + vi.resetModules(); + const store = await import("./store.js"); - const saved = await store.saveMediaSource(bogusExt); - expect(saved.contentType).toBe( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ); - expect(path.extname(saved.path)).toBe(".xlsx"); + const zip = new JSZip(); + zip.file( + "[Content_Types].xml", + '', + ); + zip.file("xl/workbook.xml", ""); + const fakeXlsx = await zip.generateAsync({ type: "nodebuffer" }); + const bogusExt = path.join(home, "sheet.bin"); + await fs.writeFile(bogusExt, fakeXlsx); + + const saved = await store.saveMediaSource(bogusExt); + expect(saved.contentType).toBe( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + expect(path.extname(saved.path)).toBe(".xlsx"); + }); }); }); diff --git a/src/web/logout.test.ts b/src/web/logout.test.ts index 3a7aea06a..7a9242cca 100644 --- a/src/web/logout.test.ts +++ b/src/web/logout.test.ts @@ -1,10 +1,10 @@ import fs from "node:fs"; -import fsPromises from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; + const runtime = { log: vi.fn(), error: vi.fn(), @@ -12,84 +12,74 @@ const runtime = { }; describe("web logout", () => { - const origHomedir = os.homedir; - let tmpDir: string; - beforeEach(() => { vi.clearAllMocks(); - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-logout-")); - vi.spyOn(os, "homedir").mockReturnValue(tmpDir); - vi.resetModules(); - vi.doMock("../utils.js", async () => { - const actual = - await vi.importActual("../utils.js"); - return { - ...actual, - CONFIG_DIR: path.join(tmpDir, ".clawdbot"), - }; - }); }); - afterEach(async () => { + afterEach(() => { vi.restoreAllMocks(); - vi.doUnmock("../utils.js"); - await fsPromises - .rm(tmpDir, { recursive: true, force: true }) - .catch(() => {}); - // restore for safety - // eslint-disable-next-line @typescript-eslint/unbound-method - (os.homedir as unknown as typeof origHomedir) = origHomedir; }); it( "deletes cached credentials when present", { timeout: 15_000 }, async () => { - const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); + await withTempHome(async (home) => { + vi.resetModules(); + const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); - const normalizedAuthDir = path.resolve(WA_WEB_AUTH_DIR); - const normalizedHome = path.resolve(tmpDir); - if (process.platform === "win32") { - expect( - normalizedAuthDir - .toLowerCase() - .startsWith(normalizedHome.toLowerCase()), - ).toBe(true); - } else { - expect(normalizedAuthDir.startsWith(normalizedHome)).toBe(true); - } - fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true }); - fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}"); - const result = await logoutWeb({ runtime: runtime as never }); + const rel = path.relative( + path.resolve(home), + path.resolve(WA_WEB_AUTH_DIR), + ); + expect(rel && !rel.startsWith("..") && !path.isAbsolute(rel)).toBe( + true, + ); - expect(result).toBe(true); - expect(fs.existsSync(WA_WEB_AUTH_DIR)).toBe(false); + fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true }); + fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}"); + const result = await logoutWeb({ runtime: runtime as never }); + + expect(result).toBe(true); + expect(fs.existsSync(WA_WEB_AUTH_DIR)).toBe(false); + }); }, ); it("no-ops when nothing to delete", { timeout: 15_000 }, async () => { - const { logoutWeb } = await import("./session.js"); - const result = await logoutWeb({ runtime: runtime as never }); - expect(result).toBe(false); - expect(runtime.log).toHaveBeenCalled(); + await withTempHome(async () => { + vi.resetModules(); + const { logoutWeb } = await import("./session.js"); + const result = await logoutWeb({ runtime: runtime as never }); + expect(result).toBe(false); + expect(runtime.log).toHaveBeenCalled(); + }); }); it("keeps shared oauth.json when using legacy auth dir", async () => { - const { logoutWeb } = await import("./session.js"); - const credsDir = path.join(tmpDir, ".clawdbot", "credentials"); - fs.mkdirSync(credsDir, { recursive: true }); - fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); - fs.writeFileSync(path.join(credsDir, "oauth.json"), '{"token":true}'); - fs.writeFileSync(path.join(credsDir, "session-abc.json"), "{}"); + await withTempHome(async () => { + vi.resetModules(); + const { logoutWeb } = await import("./session.js"); - const result = await logoutWeb({ - authDir: credsDir, - isLegacyAuthDir: true, - runtime: runtime as never, + const { resolveOAuthDir } = await import("../config/paths.js"); + const credsDir = resolveOAuthDir(); + + fs.mkdirSync(credsDir, { recursive: true }); + fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); + fs.writeFileSync(path.join(credsDir, "oauth.json"), '{"token":true}'); + fs.writeFileSync(path.join(credsDir, "session-abc.json"), "{}"); + + const result = await logoutWeb({ + authDir: credsDir, + isLegacyAuthDir: true, + runtime: runtime as never, + }); + expect(result).toBe(true); + expect(fs.existsSync(path.join(credsDir, "oauth.json"))).toBe(true); + expect(fs.existsSync(path.join(credsDir, "creds.json"))).toBe(false); + expect(fs.existsSync(path.join(credsDir, "session-abc.json"))).toBe( + false, + ); }); - expect(result).toBe(true); - expect(fs.existsSync(path.join(credsDir, "oauth.json"))).toBe(true); - expect(fs.existsSync(path.join(credsDir, "creds.json"))).toBe(false); - expect(fs.existsSync(path.join(credsDir, "session-abc.json"))).toBe(false); }); });