diff --git a/AGENTS.md b/AGENTS.md index 27fcf06c0d6..e96c339ac7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,6 +219,8 @@ Skills own workflows; root owns hard policy and routing. - Prefer behavior tests over workflow/docs string greps. Put operator policy reminders in AGENTS/docs. - QA scenario sources are YAML only: `qa/scenarios/index.yaml` and `qa/scenarios//*.yaml`. Do not add fenced `qa-scenario`/`qa-flow` Markdown files under `qa/scenarios/`. - Clean timers/env/globals/mocks/sockets/temp dirs/module state; `--isolate=false` safe. +- Tests asserting resolver/root-containment paths: `fs.realpath` mkdtemp/tmp roots first. macOS `os.tmpdir()` is a `/var` -> `/private/var` symlink; prod resolvers return canonical paths, so raw mkdtemp assertions pass on Linux CI but fail on Mac. +- Explicit `vi.mock` factories must export every binding prod touches, including error classes used in `instanceof` checks; `vi.importActual` the defining module for those instead of stub classes. - Prefer injection and narrow `*.runtime.ts` mocks over broad barrels or `openclaw/plugin-sdk/*`. - Do not edit baseline/inventory/ignore/snapshot/expected-failure files to silence checks without explicit approval. - Do not run independent `pnpm test`/Vitest commands concurrently in one worktree; Vitest cache races with `ENOTEMPTY`. Group one command or use distinct `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH`. diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts index b8fce7a64fc..d14ccd0b36c 100644 --- a/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts +++ b/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts @@ -34,24 +34,31 @@ const sendTextMock = vi.hoisted(() => ); const audioFileToSilkBase64Mock = vi.hoisted(() => vi.fn(async () => "silk-base64")); -vi.mock("../messaging/sender.js", () => ({ - accountToCreds: (account: GatewayAccount) => ({ - appId: account.appId, - clientSecret: account.clientSecret, - }), - buildDeliveryTarget: (target: { type: string; senderId: string; groupOpenid?: string }) => ({ - type: target.type === "group" ? "group" : target.type === "c2c" ? "c2c" : target.type, - id: target.type === "group" ? target.groupOpenid : target.senderId, - }), - initApiConfig: vi.fn(), - sendFileMessage: vi.fn(), - sendImage: vi.fn(), - sendText: sendTextMock, - sendVideoMessage: vi.fn(), - sendVoiceMessage: sendVoiceMessageMock, - sendMedia: sendMediaMock, - withTokenRetry: async (_creds: unknown, fn: () => Promise) => await fn(), -})); +vi.mock("../messaging/sender.js", async () => { + // Real error class so prod `instanceof UploadDailyLimitExceededError` checks + // in error paths don't trip vitest's missing-export guard on this mock. + const { UploadDailyLimitExceededError } = + await vi.importActual("../api/media-chunked.js"); + return { + accountToCreds: (account: GatewayAccount) => ({ + appId: account.appId, + clientSecret: account.clientSecret, + }), + buildDeliveryTarget: (target: { type: string; senderId: string; groupOpenid?: string }) => ({ + type: target.type === "group" ? "group" : target.type === "c2c" ? "c2c" : target.type, + id: target.type === "group" ? target.groupOpenid : target.senderId, + }), + initApiConfig: vi.fn(), + sendFileMessage: vi.fn(), + sendImage: vi.fn(), + sendText: sendTextMock, + sendVideoMessage: vi.fn(), + sendVoiceMessage: sendVoiceMessageMock, + sendMedia: sendMediaMock, + UploadDailyLimitExceededError, + withTokenRetry: async (_creds: unknown, fn: () => Promise) => await fn(), + }; +}); vi.mock("../utils/image-size.js", async () => { const actual = @@ -309,7 +316,9 @@ describe("dispatchOutbound", () => { }); it("loads scoped media through host read callbacks", async () => { - const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qqbot-host-read-")); + // realpath: macOS tmpdir is a /var -> /private/var symlink and root + // containment checks compare against canonicalized roots. + const tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "qqbot-host-read-"))); try { const mediaPath = path.join(tmpRoot, "host-report.txt"); const mediaReadFile = vi.fn(async () => Buffer.from("host report")); @@ -370,7 +379,11 @@ describe("dispatchOutbound", () => { }); it("lets missing voice files inside scoped outbound roots reach the voice wait path", async () => { - const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qqbot-scoped-voice-")); + // realpath: missing-path resolution returns canonicalized-root joins, so a + // symlinked macOS tmpdir root would change the asserted voice path. + const tmpRoot = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "qqbot-scoped-voice-")), + ); try { const missingVoicePath = path.join(tmpRoot, "pending.wav"); const runtime = makeRuntime({ diff --git a/extensions/qqbot/src/engine/messaging/outbound-media-send.test.ts b/extensions/qqbot/src/engine/messaging/outbound-media-send.test.ts index 91cc9bc6ba3..97b0a0d0db4 100644 --- a/extensions/qqbot/src/engine/messaging/outbound-media-send.test.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-media-send.test.ts @@ -101,7 +101,11 @@ function makeCtx() { beforeEach(async () => { vi.clearAllMocks(); originalOpenClawHome = process.env.OPENCLAW_HOME; - openclawHome = await fs.mkdtemp(path.join(os.tmpdir(), "qqbot-host-read-voice-")); + // realpath: macOS tmpdir is a /var -> /private/var symlink and trusted-root + // resolution returns canonicalized paths that assertions compare against. + openclawHome = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "qqbot-host-read-voice-")), + ); process.env.OPENCLAW_HOME = openclawHome; audioPortMock.audioFileToSilkBase64.mockResolvedValue(undefined); audioPortMock.isAudioFile.mockReturnValue(true);