fix(google-meet): publish exports atomically (#122306)

This commit is contained in:
Peter Steinberger
2026-08-11 15:29:08 -07:00
committed by GitHub
parent b350f76484
commit c4fd5ad551
2 changed files with 104 additions and 4 deletions
@@ -0,0 +1,88 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import JSZip from "jszip";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeMeetExportBundle } from "./cli-export.js";
import type { GoogleMeetArtifactsResult, GoogleMeetAttendanceResult } from "./meet-api.js";
const emptyArtifacts: GoogleMeetArtifactsResult = {
conferenceRecords: [],
artifacts: [],
};
const emptyAttendance: GoogleMeetAttendanceResult = {
conferenceRecords: [],
attendance: [],
};
describe("Google Meet export publication", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-export-publication-"));
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("keeps an existing bundle member when replacement fails", async () => {
const outputDir = path.join(tempDir, "bundle");
const summaryPath = path.join(outputDir, "summary.md");
fs.mkdirSync(outputDir);
fs.writeFileSync(summaryPath, "previous summary\n");
const priorBytes = fs.readFileSync(summaryPath);
vi.spyOn(fsp, "writeFile").mockImplementationOnce(async (file) => {
expect(typeof file).toBe("string");
fs.writeFileSync(file as string, "partial replacement");
throw new Error("injected write failure");
});
await expect(
writeMeetExportBundle({
outputDir,
artifacts: emptyArtifacts,
attendance: emptyAttendance,
}),
).rejects.toThrow("injected write failure");
expect(fs.readFileSync(summaryPath)).toEqual(priorBytes);
expect(fs.readdirSync(outputDir)).toEqual(["summary.md"]);
});
it("keeps an existing ZIP when replacement fails", async () => {
const outputDir = path.join(tempDir, "bundle");
const zipPath = `${outputDir}.zip`;
const priorZip = await new JSZip()
.file("previous.txt", "previous export")
.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(zipPath, priorZip);
const realWriteFile = fsp.writeFile;
vi.spyOn(fsp, "writeFile").mockImplementation(async (...args) => {
const [file, data] = args;
if (Buffer.isBuffer(data)) {
expect(typeof file).toBe("string");
fs.writeFileSync(file as string, "partial replacement");
throw new Error("injected ZIP write failure");
}
await Reflect.apply(realWriteFile, fsp, args);
});
await expect(
writeMeetExportBundle({
outputDir,
artifacts: emptyArtifacts,
attendance: emptyAttendance,
zip: true,
}),
).rejects.toThrow("injected ZIP write failure");
expect(fs.readFileSync(zipPath)).toEqual(priorZip);
expect(fs.readdirSync(tempDir).toSorted()).toEqual(["bundle", "bundle.zip"]);
});
});
+16 -4
View File
@@ -1,6 +1,7 @@
import { mkdir, writeFile } from "node:fs/promises";
import fsp from "node:fs/promises";
import path from "node:path";
import JSZip from "jszip";
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import { listGoogleMeetCalendarEvents, type GoogleMeetCalendarLookupResult } from "./calendar.js";
import {
formatDuration,
@@ -522,6 +523,17 @@ function defaultExportDirectory(): string {
return `google-meet-export-${new Date().toISOString().replace(/[:.]/g, "-")}`;
}
async function publishMeetExportFile(outputPath: string, content: string | Buffer): Promise<void> {
const absolutePath = path.resolve(outputPath);
await writeExternalFileWithinRoot({
rootDir: path.dirname(absolutePath),
path: path.basename(absolutePath),
write: async (tempPath) => {
await fsp.writeFile(tempPath, content);
},
});
}
export async function writeMeetExportBundle(params: {
outputDir?: string;
artifacts: GoogleMeetArtifactsResult;
@@ -532,7 +544,7 @@ export async function writeMeetExportBundle(params: {
calendarEvent?: GoogleMeetCalendarLookupResult;
}): Promise<{ outputDir: string; files: string[]; zipFile?: string }> {
const outputDir = params.outputDir?.trim() || defaultExportDirectory();
await mkdir(outputDir, { recursive: true });
await fsp.mkdir(outputDir, { recursive: true });
const zipFile = params.zip ? `${outputDir.replace(/\/$/, "")}.zip` : undefined;
const fileNames = googleMeetExportFileNames();
const files = [
@@ -562,7 +574,7 @@ export async function writeMeetExportBundle(params: {
},
];
for (const file of files) {
await writeFile(path.join(outputDir, file.name), file.content, "utf8");
await publishMeetExportFile(path.join(outputDir, file.name), file.content);
}
const result: { outputDir: string; files: string[]; zipFile?: string } = {
outputDir,
@@ -573,7 +585,7 @@ export async function writeMeetExportBundle(params: {
for (const file of files) {
zip.file(file.name, file.content);
}
await writeFile(zipFile, await zip.generateAsync({ type: "nodebuffer" }));
await publishMeetExportFile(zipFile, await zip.generateAsync({ type: "nodebuffer" }));
result.zipFile = zipFile;
}
return result;