diff --git a/src/commands/backup.atomic.test.ts b/src/commands/backup.atomic.test.ts index 192631bc8f93..e914e553e985 100644 --- a/src/commands/backup.atomic.test.ts +++ b/src/commands/backup.atomic.test.ts @@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; import { backupVerifyCommandMock, + createMockTarStream, createBackupTestRuntime, mockStateOnlyBackupPlan, resetBackupTempHome, @@ -70,7 +71,7 @@ describe("backupCreateCommand atomic archive write", () => { archivePrefix: "openclaw-backup-failure-", }); try { - tarCreateMock.mockRejectedValueOnce(new Error("disk full")); + tarCreateMock.mockReturnValueOnce(createMockTarStream({ error: new Error("disk full") })); await expect( backupCreateCommand(runtime, { @@ -93,9 +94,7 @@ describe("backupCreateCommand atomic archive write", () => { const realLink = fs.link.bind(fs); const linkSpy = vi.spyOn(fs, "link"); try { - tarCreateMock.mockImplementationOnce(async ({ file }: { file: string }) => { - await fs.writeFile(file, "archive-bytes", "utf8"); - }); + tarCreateMock.mockReturnValueOnce(createMockTarStream()); linkSpy.mockImplementationOnce(async (existingPath, newPath) => { await fs.writeFile(newPath, "concurrent-archive", "utf8"); return await realLink(existingPath, newPath); @@ -120,9 +119,7 @@ describe("backupCreateCommand atomic archive write", () => { }); const linkSpy = vi.spyOn(fs, "link"); try { - tarCreateMock.mockImplementationOnce(async ({ file }: { file: string }) => { - await fs.writeFile(file, "archive-bytes", "utf8"); - }); + tarCreateMock.mockReturnValueOnce(createMockTarStream()); linkSpy.mockRejectedValueOnce( Object.assign(new Error("hard links not supported"), { code: "EOPNOTSUPP" }), ); diff --git a/src/commands/backup.test-support.ts b/src/commands/backup.test-support.ts index 50aaa4e50ec2..fd2335d87b92 100644 --- a/src/commands/backup.test-support.ts +++ b/src/commands/backup.test-support.ts @@ -1,6 +1,7 @@ // Backup test support provides temp config/state fixtures and mocked backup runtime helpers. import fs from "node:fs/promises"; import path from "node:path"; +import { Readable } from "node:stream"; import { vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import { deleteTestEnvValue } from "../test-utils/env.js"; @@ -14,6 +15,24 @@ const backupTestMocks = vi.hoisted(() => ({ export const { backupVerifyCommandMock, tarCreateMock } = backupTestMocks; +export function createMockTarStream( + params: { + beforeRead?: () => Promise | void; + contents?: string; + error?: Error; + } = {}, +): Readable { + return Readable.from( + (async function* () { + await params.beforeRead?.(); + if (params.error) { + throw params.error; + } + yield params.contents ?? "archive-bytes"; + })(), + ); +} + vi.mock("tar", () => ({ c: backupTestMocks.tarCreateMock, })); diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index 7506a4428a94..ceef61de0b36 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -17,6 +17,7 @@ import { } from "./backup-shared.js"; import { backupVerifyCommandMock, + createMockTarStream, createBackupTestRuntime, mockStateOnlyBackupPlan, resetBackupTempHome, @@ -78,9 +79,7 @@ describe("backup commands", () => { beforeEach(async () => { await resetBackupTempHome(tempHome); tarCreateMock.mockReset(); - tarCreateMock.mockImplementation(async ({ file }: { file: string }) => { - await fs.writeFile(file, "archive-bytes", "utf8"); - }); + tarCreateMock.mockImplementation(() => createMockTarStream()); backupVerifyCommandMock.mockReset(); backupVerifyCommandMock.mockResolvedValue({ ok: true, @@ -268,17 +267,16 @@ describe("backup commands", () => { }), ); tarCreateMock.mockImplementationOnce( - async ( - options: { file: string; onWriteEntry?: (entry: { path: string }) => void }, - entryPaths: string[], - ) => { - capturedManifest = JSON.parse( - await fs.readFile(entryPaths[0], "utf8"), - ) as CapturedBackupManifest; - capturedEntryPaths = entryPaths; - capturedOnWriteEntry = options.onWriteEntry ?? null; - await fs.writeFile(options.file, "archive-bytes", "utf8"); - }, + (options: { onWriteEntry?: (entry: { path: string }) => void }, entryPaths: string[]) => + createMockTarStream({ + beforeRead: async () => { + capturedManifest = JSON.parse( + await fs.readFile(entryPaths[0], "utf8"), + ) as CapturedBackupManifest; + capturedEntryPaths = entryPaths; + capturedOnWriteEntry = options.onWriteEntry ?? null; + }, + }), ); const result = await backupCreateCommand(runtime, { output: backupDir, @@ -367,21 +365,20 @@ describe("backup commands", () => { const runtime = createBackupTestRuntime(); await mockStateOnlyBackupPlan(stateDir); tarCreateMock.mockImplementationOnce( - async ( - options: { file: string; filter?: (entryPath: string) => boolean }, - entryPaths: string[], - ) => { - const manifestPath = entryPaths[0]; - const stateRoot = entryPaths[1]; - if (!manifestPath || !stateRoot) { - throw new Error("backup test expected manifest and state entries"); - } - expect(options.filter?.(manifestPath)).toBe(true); - expect( - options.filter?.(path.join(stateRoot, "agents", "main", "sessions", "s.jsonl")), - ).toBe(false); - await fs.writeFile(options.file, "archive-bytes", "utf8"); - }, + (options: { filter?: (entryPath: string) => boolean }, entryPaths: string[]) => + createMockTarStream({ + beforeRead: () => { + const manifestPath = entryPaths[0]; + const stateRoot = entryPaths[1]; + if (!manifestPath || !stateRoot) { + throw new Error("backup test expected manifest and state entries"); + } + expect(options.filter?.(manifestPath)).toBe(true); + expect( + options.filter?.(path.join(stateRoot, "agents", "main", "sessions", "s.jsonl")), + ).toBe(false); + }, + }), ); const result = await backupCreateCommand(runtime, {