mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-29 17:19:00 +08:00
fix: custom packages retain stale installed plugins (#160099)
* fix(packaging): adopt plugins selected for custom distributions * test: close composed-plugin state before cleanup * test: align CI planner fixtures with canonical owners * test: export forced-exit assertion for activation fixtures * test: compare selected CI owner execution policy
This commit is contained in:
@@ -85,7 +85,9 @@ node scripts/package-openclaw-for-docker.mjs \
|
||||
shasum -a 256 ".artifacts/cloud-node/openclaw-cloud-${source_sha}.tgz"
|
||||
```
|
||||
|
||||
Run this in a clean, trusted checkout with dependencies installed. The builder compiles the runtime, includes the selected plugin's built entrypoints and import closure, and regenerates the installation inventory. It temporarily adds the plugin's exact runtime dependency pins to the distribution manifest, rejecting conflicting or unpinned dependencies, then restores the source manifest and inventory. Repeat `--bundle-plugin <id>` for additional source plugins. Without that option, the ordinary core package and external plugin publication contracts are unchanged.
|
||||
Run this in a clean, trusted checkout with dependencies installed. The builder compiles the runtime, includes the selected plugin's built entrypoints and import closure, and regenerates the installation inventory. It temporarily adds the plugin's exact runtime dependency pins to the distribution manifest, rejecting conflicting or unpinned dependencies, and marks the selected plugin as distribution-owned. Source manifests and inventory are restored after packaging. Repeat `--bundle-plugin <id>` for additional source plugins. Without that option, the ordinary core package and external plugin publication contracts are unchanged.
|
||||
|
||||
During an update, Doctor removes eligible managed npm copies that shadow the plugin included in this distribution, even when their version strings match. Copies retained by another operation keep their existing protection. Plugin settings and data remain in the state directory. Include runtime plugins that must match an unreleased host SDK in the same custom package; a core-only archive cannot update their separately published code.
|
||||
|
||||
Deliver the resulting archive through your existing immutable artifact path and verify its SHA-256 before installing it with normal npm lifecycle scripts enabled. Record both source SHA and archive digest: different unreleased builds can share a version. Do not copy a plugin into an installed release or substitute a standalone `npm-pack:` plugin archive for this distribution. Cloud profiles do not consume this URL; their enrollment artifact comes from the running Gateway.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
composePackagePlugins,
|
||||
type DistributionPackageManifest,
|
||||
} from "../../src/infra/package-plugin-composition.ts";
|
||||
import type { PackageManifest } from "../../src/plugins/package-manifest.types.ts";
|
||||
import { NON_PACKAGED_BUNDLED_PLUGIN_DIRS } from "../../src/shared/non-packaged-plugin-dirs.ts";
|
||||
import {
|
||||
collectBundledPluginBuildEntries,
|
||||
@@ -13,10 +14,13 @@ import {
|
||||
DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV,
|
||||
} from "./bundled-plugin-build-entries.mjs";
|
||||
import { assertRealOutputRoot } from "./output-root-guard.mjs";
|
||||
import { PACKAGE_DIST_INVENTORY_RELATIVE_PATH } from "./package-dist-inventory-contract.mts";
|
||||
import {
|
||||
PACKAGE_DIST_CONTENT_INVENTORY_RELATIVE_PATH,
|
||||
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
|
||||
} from "./package-dist-inventory-contract.mts";
|
||||
import { PACKAGE_LIFECYCLE_PENDING_RELATIVE_PATH } from "./package-lifecycle-marker.mjs";
|
||||
|
||||
type PackageJson = DistributionPackageManifest;
|
||||
type PackageJson = DistributionPackageManifest & Pick<PackageManifest, "openclaw">;
|
||||
|
||||
export function resolvePackageBundledPlugins(sourceDir: string, pluginIds: string[]) {
|
||||
const ids = [...new Set(pluginIds)].toSorted();
|
||||
@@ -40,7 +44,11 @@ export function resolvePackageBundledPlugins(sourceDir: string, pluginIds: strin
|
||||
}
|
||||
|
||||
/** Called under the canonical packer's source lifecycle lock, before bundling workspace deps. */
|
||||
export async function preparePackageBundledPlugins(sourceDir: string, pluginIds: string[]) {
|
||||
export async function preparePackageBundledPlugins(
|
||||
sourceDir: string,
|
||||
pluginIds: string[],
|
||||
onCleanupFailure: (error: Error) => void,
|
||||
) {
|
||||
const selected = resolvePackageBundledPlugins(sourceDir, pluginIds);
|
||||
if (selected.length === 0) {
|
||||
return async () => {};
|
||||
@@ -49,6 +57,7 @@ export async function preparePackageBundledPlugins(sourceDir: string, pluginIds:
|
||||
const packagePath = path.join(sourceDir, "package.json");
|
||||
const original = await fs.readFile(packagePath, "utf8");
|
||||
const sourcePackageJson = JSON.parse(original) as PackageJson;
|
||||
const pluginPackages = new Map<string, PackageJson>();
|
||||
for (const { id, sourceEntries } of selected) {
|
||||
const sourcePackage = JSON.parse(
|
||||
await fs.readFile(path.join(sourceDir, "extensions", id, "package.json"), "utf8"),
|
||||
@@ -80,6 +89,7 @@ export async function preparePackageBundledPlugins(sourceDir: string, pluginIds:
|
||||
for (const entry of sourceEntries) {
|
||||
await fs.access(path.join(pluginRoot, entry.replace(/\.[^.]+$/u, ".js")));
|
||||
}
|
||||
pluginPackages.set(`dist/extensions/${id}/package.json`, builtPackage);
|
||||
}
|
||||
const packageJson = composePackagePlugins(
|
||||
sourcePackageJson,
|
||||
@@ -92,7 +102,9 @@ export async function preparePackageBundledPlugins(sourceDir: string, pluginIds:
|
||||
[
|
||||
"package.json",
|
||||
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
|
||||
PACKAGE_DIST_CONTENT_INVENTORY_RELATIVE_PATH,
|
||||
PACKAGE_LIFECYCLE_PENDING_RELATIVE_PATH,
|
||||
...pluginPackages.keys(),
|
||||
].map(async (relativePath) => {
|
||||
const target = path.join(sourceDir, relativePath);
|
||||
const bytes = await fs.readFile(target).catch((error: unknown) => {
|
||||
@@ -112,7 +124,7 @@ export async function preparePackageBundledPlugins(sourceDir: string, pluginIds:
|
||||
);
|
||||
const failures = results.filter((result) => result.status === "rejected");
|
||||
if (failures.length) {
|
||||
throw new AggregateError(
|
||||
const error = new AggregateError(
|
||||
[
|
||||
...(preparationFailure ? [preparationFailure.cause] : []),
|
||||
...failures.map((result) => result.reason),
|
||||
@@ -120,10 +132,24 @@ export async function preparePackageBundledPlugins(sourceDir: string, pluginIds:
|
||||
"Selected plugin package cleanup failed",
|
||||
preparationFailure,
|
||||
);
|
||||
onCleanupFailure(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
try {
|
||||
await fs.writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
// Explicit composition transfers ownership to this distribution. Without
|
||||
// this fact, Doctor retains an older managed npm copy with the same version.
|
||||
for (const [relativePath, pluginPackage] of pluginPackages) {
|
||||
pluginPackage.openclaw = {
|
||||
...pluginPackage.openclaw,
|
||||
build: { ...pluginPackage.openclaw?.build, bundledDist: true },
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(sourceDir, relativePath),
|
||||
`${JSON.stringify(pluginPackage, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
// Inventory must see the custom manifest before pack, or postinstall would prune the plugin.
|
||||
const { writePackageDistInventoryForPublish } = await import("./package-dist-inventory.ts");
|
||||
await writePackageDistInventoryForPublish(sourceDir);
|
||||
|
||||
@@ -81,6 +81,7 @@ type PackageOptions = RunOptions & {
|
||||
allowUnreleasedChangelog?: unknown;
|
||||
extractAiRuntime?: (tarballPath: string, destination: string) => Promise<unknown>;
|
||||
normalizeTarballModes?: (tarballPath: string) => Promise<unknown>;
|
||||
onCleanupFailure?: (error: unknown) => void;
|
||||
outputName?: string;
|
||||
packJsonPath?: string;
|
||||
pnpmPack?: boolean;
|
||||
@@ -670,6 +671,7 @@ export async function prepareBundledAiRuntimePackage(
|
||||
originalAiRuntimeMoved = false;
|
||||
packedAiTarballs = [];
|
||||
if (cleanupError) {
|
||||
packageOptions.onCleanupFailure?.(cleanupError);
|
||||
throw toErrorObject(cleanupError, "Package cleanup failed.");
|
||||
}
|
||||
};
|
||||
@@ -703,6 +705,7 @@ export async function prepareBundledAiRuntimePackage(
|
||||
try {
|
||||
await restoreManifest(aiRuntimeSourceDir);
|
||||
} catch (restoreError) {
|
||||
packageOptions.onCleanupFailure?.(restoreError);
|
||||
throw packError ? packagePreparationRestoreError(packError, restoreError) : restoreError;
|
||||
}
|
||||
if (packError) {
|
||||
@@ -939,6 +942,8 @@ export async function packOpenClawPackageForDocker(
|
||||
try {
|
||||
let cleanupBundledAiRuntime = async () => {};
|
||||
let cleanupBundledPlugins = async () => {};
|
||||
const cleanupFailures = new Set<unknown>();
|
||||
const onCleanupFailure = (error: unknown) => void cleanupFailures.add(error);
|
||||
try {
|
||||
await cleanPackedOpenClawTarballs(outputPath);
|
||||
if (packageOptions.bundlePlugins?.length) {
|
||||
@@ -946,6 +951,7 @@ export async function packOpenClawPackageForDocker(
|
||||
cleanupBundledPlugins = await preparePackageBundledPlugins(
|
||||
sourcePath,
|
||||
packageOptions.bundlePlugins,
|
||||
onCleanupFailure,
|
||||
);
|
||||
}
|
||||
cleanupBundledAiRuntime = await prepareBundledAiRuntime(
|
||||
@@ -955,6 +961,7 @@ export async function packOpenClawPackageForDocker(
|
||||
{
|
||||
prepareManifest,
|
||||
restoreManifest,
|
||||
onCleanupFailure,
|
||||
},
|
||||
);
|
||||
// AI staging materializes the bundled tree; pack must not inherit the
|
||||
@@ -975,21 +982,34 @@ export async function packOpenClawPackageForDocker(
|
||||
DEFAULT_PACKAGE_PACK_TIMEOUT_MS,
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
packageError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await cleanupBundledAiRuntime();
|
||||
} finally {
|
||||
// Restore shared manifests in reverse preparation order. A helper can
|
||||
// fail restoring during preparation, before its cleanup handle returns.
|
||||
for (const cleanup of [cleanupBundledAiRuntime, cleanupBundledPlugins]) {
|
||||
try {
|
||||
await cleanupBundledPlugins();
|
||||
} finally {
|
||||
await restorePackageSourceArtifacts(
|
||||
sourcePath,
|
||||
restoreDocsMap,
|
||||
restoreManifest,
|
||||
restoreChangelog,
|
||||
);
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
onCleanupFailure(error);
|
||||
}
|
||||
}
|
||||
await restorePackageSourceArtifacts(
|
||||
sourcePath,
|
||||
async (cwd) => {
|
||||
if (cleanupFailures.size) {
|
||||
throw new AggregateError(
|
||||
new Set([...(packageError === undefined ? [] : [packageError]), ...cleanupFailures]),
|
||||
"Package source cleanup failed; packaging receipt retained.",
|
||||
{ cause: packageError },
|
||||
);
|
||||
}
|
||||
await restoreDocsMap(cwd);
|
||||
},
|
||||
restoreManifest,
|
||||
restoreChangelog,
|
||||
);
|
||||
}
|
||||
// Scan the emptied pnpm destination instead of trusting its absolute-path output.
|
||||
let tarball = await newestOpenClawTarball(
|
||||
|
||||
@@ -29,6 +29,16 @@ import {
|
||||
runCommandForTest,
|
||||
writePackageInventoryForDocker,
|
||||
} from "../../../../scripts/package-openclaw-for-docker.mts";
|
||||
import { maybeRepairPluginRegistryState } from "../../../../src/commands/doctor-plugin-registry.js";
|
||||
import {
|
||||
createCurrentIndexWithNpmRecord,
|
||||
createManagedNpmPlugin,
|
||||
hermeticEnv,
|
||||
readRequiredPersistedInstalledPluginIndex,
|
||||
} from "../../../../src/commands/doctor-plugin-registry.test-support.js";
|
||||
import { resolveInstalledPluginIndexStorePath } from "../../../../src/plugins/installed-plugin-index-store-path.js";
|
||||
import { writePersistedInstalledPluginIndex } from "../../../../src/plugins/installed-plugin-index-store-write.js";
|
||||
import { closeOpenClawStateDatabaseByPathAsync } from "../../../../src/state/openclaw-state-db.js";
|
||||
import { withEnvAsync } from "../../../../src/test-utils/env.js";
|
||||
import { createDeferred } from "../../../helpers/promise.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
|
||||
@@ -90,18 +100,22 @@ function createSelectedPluginPackageFixture() {
|
||||
optionalDependencies: { optional: "3.0.0" },
|
||||
peerDependencies: { peer: "1.0.0" },
|
||||
peerDependenciesMeta: { peer: { optional: true } },
|
||||
openclaw: { extensions: ["./index.ts"], release: { publishToNpm: true } },
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
build: { bundledDist: false },
|
||||
release: { publishToNpm: true },
|
||||
},
|
||||
};
|
||||
const files = {
|
||||
"package.json": JSON.stringify(packageJson),
|
||||
"extensions/demo/package.json": JSON.stringify(pluginPackage),
|
||||
"extensions/demo/openclaw.plugin.json": '{"id":"demo"}',
|
||||
"extensions/demo/openclaw.plugin.json": '{"id":"demo","configSchema":{"type":"object"}}',
|
||||
"extensions/demo/index.ts": "export {};",
|
||||
"dist/extensions/demo/package.json": JSON.stringify({
|
||||
...pluginPackage,
|
||||
openclaw: { ...pluginPackage.openclaw, extensions: ["./index.js"] },
|
||||
}),
|
||||
"dist/extensions/demo/openclaw.plugin.json": '{"id":"demo"}',
|
||||
"dist/extensions/demo/openclaw.plugin.json": '{"id":"demo","configSchema":{"type":"object"}}',
|
||||
"dist/extensions/demo/index.js": 'export { value } from "../../shared-runtime.js";',
|
||||
"dist/extensions/demo/node_modules/host-native/index.js": "not portable",
|
||||
"dist/extensions/other/index.js": "not selected",
|
||||
@@ -240,6 +254,73 @@ async function waitForExit(
|
||||
}
|
||||
|
||||
describe("package-openclaw-for-docker", () => {
|
||||
it("converges an existing same-version npm plugin onto the selected distribution", async () => {
|
||||
const { sourceDir, outputDir, files, pluginPackage } = createSelectedPluginPackageFixture();
|
||||
const selected = await packOpenClawPackageForDocker(sourceDir, outputDir, {
|
||||
...skipDocsMapLifecycle,
|
||||
prepareChangelog: async () => {},
|
||||
restoreChangelog: async () => {},
|
||||
bundlePlugins: ["demo"],
|
||||
});
|
||||
const extractDir = tempDirs.make("openclaw-selected-plugin-convergence-");
|
||||
await tar.x({ file: selected, cwd: extractDir });
|
||||
const pluginRoot = path.join(extractDir, "package/dist/extensions/demo");
|
||||
const packedPackage = JSON.parse(
|
||||
fs.readFileSync(path.join(pluginRoot, "package.json"), "utf8"),
|
||||
);
|
||||
const stateDir = tempDirs.make("openclaw-selected-plugin-state-");
|
||||
const managed = createManagedNpmPlugin({
|
||||
stateDir,
|
||||
id: "demo",
|
||||
packageName: pluginPackage.name,
|
||||
version: pluginPackage.version,
|
||||
});
|
||||
try {
|
||||
await writePersistedInstalledPluginIndex(
|
||||
createCurrentIndexWithNpmRecord({
|
||||
pluginId: "demo",
|
||||
packageName: pluginPackage.name,
|
||||
packageDir: managed.packageDir,
|
||||
version: pluginPackage.version,
|
||||
}),
|
||||
{ stateDir },
|
||||
);
|
||||
await maybeRepairPluginRegistryState({
|
||||
stateDir,
|
||||
env: hermeticEnv({ OPENCLAW_STATE_DIR: stateDir }),
|
||||
config: { plugins: { allow: ["demo"], entries: { demo: { enabled: true } } } },
|
||||
candidates: [
|
||||
{
|
||||
idHint: "demo",
|
||||
rootDir: pluginRoot,
|
||||
source: path.join(pluginRoot, "index.js"),
|
||||
origin: "bundled",
|
||||
packageName: packedPackage.name,
|
||||
packageVersion: packedPackage.version,
|
||||
packageManifest: packedPackage.openclaw,
|
||||
},
|
||||
],
|
||||
prompter: { shouldRepair: true },
|
||||
});
|
||||
const repaired = await readRequiredPersistedInstalledPluginIndex(stateDir);
|
||||
expect(fs.existsSync(managed.packageDir)).toBe(false);
|
||||
expect(repaired.installRecords.demo).toBeUndefined();
|
||||
expect(repaired.plugins.find((plugin) => plugin.pluginId === "demo")).toMatchObject({
|
||||
origin: "bundled",
|
||||
rootDir: pluginRoot,
|
||||
enabled: true,
|
||||
});
|
||||
expect(
|
||||
fs.readFileSync(path.join(sourceDir, "dist/extensions/demo/package.json"), "utf8"),
|
||||
).toBe(files["dist/extensions/demo/package.json"]);
|
||||
} finally {
|
||||
// Windows requires worker and native SQLite handles closed before directory cleanup.
|
||||
await closeOpenClawStateDatabaseByPathAsync(
|
||||
resolveInstalledPluginIndexStorePath({ stateDir }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"packs explicitly selected plugin runtime and dependencies without changing the ordinary package (linked dependencies=%s)",
|
||||
async (linkedDependencies) => {
|
||||
@@ -255,6 +336,12 @@ describe("package-openclaw-for-docker", () => {
|
||||
fs.symlinkSync(sourceDependency, builtDependency, "junction");
|
||||
}
|
||||
const inventoryPath = path.join(sourceDir, "dist/postinstall-inventory.json");
|
||||
const contentInventoryPath = path.join(
|
||||
sourceDir,
|
||||
PACKAGE_DIST_CONTENT_INVENTORY_RELATIVE_PATH,
|
||||
);
|
||||
await writePackageDistInventoryForPublish(sourceDir);
|
||||
const originalContentInventory = fs.readFileSync(contentInventoryPath);
|
||||
const options = {
|
||||
...skipDocsMapLifecycle,
|
||||
prepareChangelog: async () => {},
|
||||
@@ -293,6 +380,7 @@ describe("package-openclaw-for-docker", () => {
|
||||
"dist/shared-runtime.js",
|
||||
]);
|
||||
expect(fs.existsSync(path.join(sourceDir, ".openclaw-lifecycle-pending"))).toBe(false);
|
||||
expect(fs.readFileSync(contentInventoryPath)).toEqual(originalContentInventory);
|
||||
|
||||
const ordinary = await packOpenClawPackageForDocker(sourceDir, outputDir, options);
|
||||
const ordinaryEntries: string[] = [];
|
||||
@@ -617,6 +705,9 @@ describe("package-openclaw-for-docker", () => {
|
||||
|
||||
it("restores selected package metadata and inventory after npm pack fails", async () => {
|
||||
const { sourceDir, outputDir, files } = createSelectedPluginPackageFixture();
|
||||
const contentInventoryPath = path.join(sourceDir, PACKAGE_DIST_CONTENT_INVENTORY_RELATIVE_PATH);
|
||||
await writePackageDistInventoryForPublish(sourceDir);
|
||||
const originalContentInventory = fs.readFileSync(contentInventoryPath);
|
||||
const runCaptureImpl = vi.fn(async () => {
|
||||
throw new Error("pack rejected");
|
||||
});
|
||||
@@ -633,13 +724,72 @@ describe("package-openclaw-for-docker", () => {
|
||||
expect(fs.readFileSync(path.join(sourceDir, "package.json"), "utf8")).toBe(
|
||||
files["package.json"],
|
||||
);
|
||||
expect(fs.readFileSync(path.join(sourceDir, "dist/extensions/demo/package.json"), "utf8")).toBe(
|
||||
files["dist/extensions/demo/package.json"],
|
||||
);
|
||||
expect(
|
||||
JSON.parse(fs.readFileSync(path.join(sourceDir, "dist/postinstall-inventory.json"), "utf8")),
|
||||
).toEqual([PACKAGE_DIST_CONTENT_INVENTORY_RELATIVE_PATH, "dist/shared-runtime.js"]);
|
||||
expect(fs.existsSync(path.join(sourceDir, "dist/openclaw-install-guard"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sourceDir, ".openclaw-lifecycle-pending"))).toBe(false);
|
||||
expect(fs.readFileSync(contentInventoryPath)).toEqual(originalContentInventory);
|
||||
});
|
||||
|
||||
it.each(["preparation", "cleanup"] as const)(
|
||||
"retains the package receipt when selected metadata cannot be restored after %s",
|
||||
async (failurePhase) => {
|
||||
const { sourceDir, outputDir, files } = createSelectedPluginPackageFixture();
|
||||
fs.mkdirSync(path.join(sourceDir, "docs"));
|
||||
fs.writeFileSync(path.join(sourceDir, "docs/page.md"), "# Package docs\n");
|
||||
const pluginPath = path.join(sourceDir, "dist/extensions/demo/package.json");
|
||||
const inventoryPath = path.join(sourceDir, PACKAGE_DIST_CONTENT_INVENTORY_RELATIVE_PATH);
|
||||
const receiptPath = path.join(sourceDir, ".artifacts/package-docs-map/receipt.json");
|
||||
const restoreError = new Error("selected metadata restoration failed");
|
||||
const originalWrite = fs.promises.writeFile.bind(fs.promises);
|
||||
const write = vi
|
||||
.spyOn(fs.promises, "writeFile")
|
||||
.mockImplementation(async (target, data, options) => {
|
||||
if (
|
||||
target === pluginPath &&
|
||||
Buffer.isBuffer(data) &&
|
||||
data.equals(Buffer.from(files["dist/extensions/demo/package.json"]))
|
||||
) {
|
||||
throw restoreError;
|
||||
}
|
||||
if (
|
||||
failurePhase === "preparation" &&
|
||||
target === inventoryPath &&
|
||||
JSON.parse(fs.readFileSync(pluginPath, "utf8")).openclaw.build.bundledDist === true
|
||||
) {
|
||||
throw new Error("selected inventory preparation failed");
|
||||
}
|
||||
return originalWrite(target, data, options);
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
packOpenClawPackageForDocker(sourceDir, outputDir, {
|
||||
...skipTarballModeNormalization,
|
||||
prepareDocsMap: preparePackageDocsMap,
|
||||
restoreDocsMap: restorePackageDocsMap,
|
||||
prepareChangelog: async () => {},
|
||||
restoreChangelog: async () => {},
|
||||
bundlePlugins: ["demo"],
|
||||
runCaptureImpl: async () => {
|
||||
fs.writeFileSync(path.join(outputDir, "openclaw-2026.8.1.tgz"), "package");
|
||||
return "openclaw-2026.8.1.tgz\n";
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(fs.existsSync(receiptPath)).toBe(true);
|
||||
await expect(preparePackageDocsMap(sourceDir)).rejects.toMatchObject({
|
||||
code: "PACKAGE_DOCS_MAP_ACTIVE",
|
||||
});
|
||||
} finally {
|
||||
write.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects duplicate package artifact CLI options", () => {
|
||||
const duplicateCases = [
|
||||
["--output-dir", ["--output-dir", "one", "--output-dir=two"]],
|
||||
@@ -1198,6 +1348,7 @@ describe("package-openclaw-for-docker", () => {
|
||||
expect(fs.readFileSync(packageJsonPath, "utf8")).toBe(originalPackageJson);
|
||||
|
||||
const restoreError = new Error("AI manifest restore failed");
|
||||
const onCleanupFailure = vi.fn();
|
||||
await expect(
|
||||
prepareBundledAiRuntimePackage(
|
||||
sourceDir,
|
||||
@@ -1207,6 +1358,7 @@ describe("package-openclaw-for-docker", () => {
|
||||
},
|
||||
{
|
||||
prepareManifest: preparePackageManifest,
|
||||
onCleanupFailure,
|
||||
restoreManifest: async (cwd) => {
|
||||
await restorePackageManifest(cwd);
|
||||
throw restoreError;
|
||||
@@ -1214,6 +1366,7 @@ describe("package-openclaw-for-docker", () => {
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ cause: packError, errors: [packError, restoreError] });
|
||||
expect(onCleanupFailure).toHaveBeenCalledWith(restoreError);
|
||||
});
|
||||
|
||||
it("reuses the source manifest lifecycle for ignore-scripts package artifacts", async () => {
|
||||
|
||||
@@ -414,6 +414,7 @@ it("keeps UI and core changes with exact owners and direct consumers", () => {
|
||||
expect(shards?.some((shard) => shard.requiresDist)).toBe(false);
|
||||
const placement = vi.spyOn(testTimings, "readRuntimePlacementTimings").mockReturnValue([]);
|
||||
let canonical: CompactNodeTestShard[];
|
||||
let selectedCanonical: CompactNodeTestShard[];
|
||||
try {
|
||||
canonical = createNodeTestShardBundles({
|
||||
compactMode: "pull-request",
|
||||
@@ -425,6 +426,19 @@ it("keeps UI and core changes with exact owners and direct consumers", () => {
|
||||
// Match the focused selector's admitted owner inventory before comparing resources.
|
||||
includePrExemptRuntimeTests: true,
|
||||
});
|
||||
selectedCanonical = expectDefined(
|
||||
createSelectedNodeTestShardBundles(
|
||||
(shards ?? []).flatMap(
|
||||
(job) => job.groups?.flatMap((group) => group.includePatterns ?? []) ?? [],
|
||||
),
|
||||
{
|
||||
runnerBackend: options.runnerBackend,
|
||||
includeReleaseOnlyRuntimeTests: true,
|
||||
includePrExemptRuntimeTests: true,
|
||||
},
|
||||
),
|
||||
"canonical selected UI consumer owners",
|
||||
);
|
||||
} finally {
|
||||
placement.mockRestore();
|
||||
}
|
||||
@@ -466,12 +480,38 @@ it("keeps UI and core changes with exact owners and direct consumers", () => {
|
||||
expect(group).toEqual(owner);
|
||||
}
|
||||
expect(group.configs.every((config) => owner.configs.includes(config))).toBe(true);
|
||||
expect(group.env).toEqual(owner.env);
|
||||
expect(group.fallbackMaxWorkers).toBe(owner.fallbackMaxWorkers);
|
||||
expect(group.minTotalMemoryBytes).toBe(owner.minTotalMemoryBytes);
|
||||
expect(job.env).toEqual(ownerJob.env);
|
||||
expect(job.runner).toBe(ownerJob.runner);
|
||||
expect(job.planConcurrency).toBe(ownerJob.planConcurrency);
|
||||
// Tooling capacity follows selected files; an excluded compiler can require a larger full job.
|
||||
const selectedJob = expectDefined(
|
||||
selectedCanonical.find((candidate) =>
|
||||
candidate.groups.some((selected) => selected.shard_name === group.shard_name),
|
||||
),
|
||||
`selected UI consumer job for ${group.shard_name}`,
|
||||
);
|
||||
const selectedGroup = expectDefined(
|
||||
selectedJob.groups.find((selected) => selected.shard_name === group.shard_name),
|
||||
"selected UI consumer group",
|
||||
);
|
||||
for (const key of [
|
||||
"configs",
|
||||
"env",
|
||||
"runner",
|
||||
"fallbackMaxWorkers",
|
||||
"minTotalMemoryBytes",
|
||||
"pretestBuildMode",
|
||||
"requiresDist",
|
||||
] as const) {
|
||||
expect(group[key], `${group.shard_name} group ${key}`).toEqual(selectedGroup[key]);
|
||||
}
|
||||
for (const key of [
|
||||
"env",
|
||||
"runner",
|
||||
"planConcurrency",
|
||||
"pretestBuildMode",
|
||||
"requiresDist",
|
||||
"timeoutMinutes",
|
||||
] as const) {
|
||||
expect(job[key], `${group.shard_name} job ${key}`).toEqual(selectedJob[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(createChangedNodeTestShards([paths[1]!, "ui/src/AGENTS.md"], options)).toEqual(
|
||||
|
||||
Reference in New Issue
Block a user