test(qqbot): fix macOS-only outbound media test failures

The scoped outbound media tests added in #92872 assert raw mkdtemp paths,
but prod resolvers canonicalize roots, so on macOS the /var -> /private/var
tmpdir symlink made three tests fail (green on Linux CI). Realpath the tmp
roots like sibling tests, and export the real UploadDailyLimitExceededError
from the outbound-dispatch sender.js mock so prod instanceof checks in error
paths hit real assertions instead of vitest's missing-export guard.

Also add AGENTS.md test guidance covering both patterns.
This commit is contained in:
Peter Steinberger 2026-07-04 19:20:16 +01:00
parent 6da5db21a6
commit 7a92c473c1
No known key found for this signature in database
3 changed files with 40 additions and 21 deletions

View file

@ -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/<theme>/*.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`.

View file

@ -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<unknown>) => 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<typeof import("../api/media-chunked.js")>("../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<unknown>) => 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({

View file

@ -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);