From 203213028ed07eb1ab005a0a5de95c1e25d2c086 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 05:17:23 +0100 Subject: [PATCH] perf: speed plugin contract tests and fix ci --- scripts/stage-bundled-plugin-runtime-deps.mjs | 12 ++++++- .../test-helpers/contracts-testkit.ts | 7 ++--- src/plugin-sdk/test-helpers/string-utils.ts | 3 ++ src/plugins/contracts/loader.contract.test.ts | 2 +- .../contracts/registry.contract.test.ts | 2 +- .../stage-bundled-plugin-runtime-deps.test.ts | 31 +++++++++++++++++++ 6 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 src/plugin-sdk/test-helpers/string-utils.ts diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index ebb67293638..42d22445fcc 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -26,7 +26,17 @@ function readOptionalUtf8(filePath) { } function removePathIfExists(targetPath) { - fs.rmSync(targetPath, { recursive: true, force: true }); + for (let attempt = 0; ; attempt += 1) { + try { + fs.rmSync(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + if (!isTransientTempRemoveError(error) || attempt >= TEMP_REMOVE_RETRY_DELAYS_MS.length) { + throw error; + } + sleepSync(TEMP_REMOVE_RETRY_DELAYS_MS[attempt]); + } + } } function isTransientTempRemoveError(error) { diff --git a/src/plugin-sdk/test-helpers/contracts-testkit.ts b/src/plugin-sdk/test-helpers/contracts-testkit.ts index 1569ad900eb..5cb9a0fa5e2 100644 --- a/src/plugin-sdk/test-helpers/contracts-testkit.ts +++ b/src/plugin-sdk/test-helpers/contracts-testkit.ts @@ -8,12 +8,9 @@ import { type PluginRecord, type PluginRuntime, } from "../testing.js"; +import { uniqueSortedStrings } from "./string-utils.js"; -export { registerProviders, requireProvider }; - -export function uniqueSortedStrings(values: readonly string[]) { - return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); -} +export { registerProviders, requireProvider, uniqueSortedStrings }; function formatImportSideEffectCall(args: readonly unknown[]): string { if (args.length === 0) { diff --git a/src/plugin-sdk/test-helpers/string-utils.ts b/src/plugin-sdk/test-helpers/string-utils.ts new file mode 100644 index 00000000000..dc06b627c0d --- /dev/null +++ b/src/plugin-sdk/test-helpers/string-utils.ts @@ -0,0 +1,3 @@ +export function uniqueSortedStrings(values: readonly string[]) { + return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); +} diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index 5e921b679cf..78442219e53 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -1,5 +1,5 @@ -import { uniqueSortedStrings } from "openclaw/plugin-sdk/plugin-test-contracts"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { uniqueSortedStrings } from "../../plugin-sdk/test-helpers/string-utils.js"; import { withBundledPluginAllowlistCompat } from "../bundled-compat.js"; import { resolveManifestContractPluginIds } from "../plugin-registry.js"; import { __testing as providerTesting } from "../providers.js"; diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 5aa46c71fb5..a9e953d02e0 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -1,5 +1,5 @@ -import { uniqueSortedStrings } from "openclaw/plugin-sdk/plugin-test-contracts"; import { describe, expect, it } from "vitest"; +import { uniqueSortedStrings } from "../../plugin-sdk/test-helpers/string-utils.js"; import { loadPluginManifestRegistry } from "../manifest-registry.js"; import { resolveManifestContractPluginIds } from "../plugin-registry.js"; import { diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index 784a251ceab..88f0d74ff1a 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -420,6 +420,37 @@ describe("stageBundledPluginRuntimeDeps", () => { expect(fs.existsSync(path.join(targetPath, "owner.json"))).toBe(false); }); + it("retries transient backup cleanup during atomic replace", () => { + const parentDir = createTempDir("openclaw-runtime-deps-replace-"); + const targetPath = path.join(parentDir, "node_modules"); + const sourcePath = path.join(parentDir, "source-node_modules"); + fs.mkdirSync(targetPath, { recursive: true }); + fs.writeFileSync(path.join(targetPath, "marker.txt"), "original\n", "utf8"); + fs.mkdirSync(sourcePath, { recursive: true }); + fs.writeFileSync(path.join(sourcePath, "marker.txt"), "replacement\n", "utf8"); + + const realRmSync = fs.rmSync.bind(fs); + let transientFailures = 0; + vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { + const targetString = String(target); + if ( + targetString.includes(`${path.sep}.openclaw-runtime-deps-backup-`) && + transientFailures < 2 + ) { + transientFailures += 1; + const error = new Error("transient backup cleanup failure") as NodeJS.ErrnoException; + error.code = "ENOTEMPTY"; + throw error; + } + return realRmSync(target, options); + }); + + stageBundledPluginRuntimeDepsTesting.replaceDirAtomically(targetPath, sourcePath); + + expect(transientFailures).toBe(2); + expect(fs.readFileSync(path.join(targetPath, "marker.txt"), "utf8")).toBe("replacement\n"); + }); + it("restages when installed root runtime dependency contents change", () => { const { pluginDir, repoRoot } = createBundledPluginFixture({ packageJson: {