openclaw / src /cli /update-cli /update-command-artifact.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw
History Blame Contribute Delete
10.4 kB
import { execFileSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, assert, expect, it, vi } from "vitest";
import { writePackageDistInventory } from "../../../scripts/lib/package-dist-inventory.js";
import type { PackageUpdateTransaction } from "../../infra/package-update-steps.js";
import {
createNpmTarget,
writePackageRoot,
} from "../../infra/package-update-steps.test-support.js";
import * as privateTempWorkspace from "../../infra/private-temp-workspace.js";
import * as tempRoot from "../../infra/tmp-openclaw-dir.js";
import { withTestDir } from "../../test-helpers/temp-dir.js";
import { runFreshUpdateArtifact } from "./update-command-artifact.js";
import * as packageUpdate from "./update-command-package.js";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it.each(["activate", "refuse", "directory"] as const)(
"isolates real npm lifecycle state until artifact %s",
async (action) => {
await withTestDir({ prefix: "fresh-artifact-lifecycle-" }, async (base) => {
const home = path.join(base, "home");
const canonicalState = path.join(home, "state");
const canonicalConfig = path.join(canonicalState, "openclaw.json");
const canonicalTmp = path.join(base, "inherited-tmp");
await fs.mkdir(canonicalState, { recursive: true });
await fs.mkdir(canonicalTmp);
await fs.writeFile(canonicalConfig, "{}\n");
vi.stubEnv("HOME", home);
vi.stubEnv("OPENCLAW_STATE_DIR", canonicalState);
vi.stubEnv("OPENCLAW_CONFIG_PATH", canonicalConfig);
vi.stubEnv("STATE_DIRECTORY", canonicalState);
vi.stubEnv("NODE_COMPILE_CACHE", canonicalTmp);
vi.stubEnv("OPENCLAW_AGENT_DIR", canonicalState);
vi.stubEnv(
"OPENCLAW_UPDATE_POST_CORE_RESULT_PATH",
path.join(canonicalState, "continuation"),
);
const target = createNpmTarget(path.join(base, "prefix", "lib", "node_modules"));
target.npmOwner = { version: "11.10.0", lifecyclePolicy: "unflagged" };
const root = target.packageRoot!;
await writePackageRoot(root, "1.0.0");
const source = path.join(base, "package");
await writePackageRoot(source, "1.0.1");
await fs.writeFile(
path.join(source, "package.json"),
JSON.stringify({
name: "openclaw",
version: "1.0.1",
type: "module",
bin: { openclaw: "dist/index.js" },
openclaw: { schemaVersions: { state: 17, agent: 19 } },
scripts: { postinstall: "node lifecycle.cjs" },
}),
);
await fs.writeFile(
path.join(source, "lifecycle.cjs"),
`const fs=require('node:fs'),path=require('node:path'),os=require('node:os');
fs.mkdirSync(process.env.OPENCLAW_STATE_DIR,{recursive:true});
fs.writeFileSync(path.join(process.env.OPENCLAW_STATE_DIR,'lifecycle'),'private');
fs.writeFileSync(path.join(os.tmpdir(),'lifecycle'),'private');
fs.writeFileSync('lifecycle-paths.json',JSON.stringify({home:process.env.HOME,openclawHome:process.env.OPENCLAW_HOME,state:process.env.OPENCLAW_STATE_DIR,tmp:os.tmpdir()}));
for(const key of ['STATE_DIRECTORY','OPENCLAW_AGENT_DIR','NODE_COMPILE_CACHE']) {
if(process.env[key]) fs.writeFileSync(path.join(process.env[key],'leaked'),'bad');
}
if(process.env.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH) fs.writeFileSync(process.env.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH,'bad');
`,
);
await fs.writeFile(
path.join(source, "dist", "index.js"),
`#!/usr/bin/env node
import fs from 'node:fs';
if(process.argv.includes('doctor')) fs.writeFileSync(process.env.OPENCLAW_CONFIG_PATH, JSON.stringify({doctorState:process.env.OPENCLAW_STATE_DIR}));
`,
);
await fs.chmod(path.join(source, "dist", "index.js"), 0o755);
await writePackageDistInventory(source);
const archive = path.join(base, "candidate.tgz");
execFileSync("tar", ["-czf", archive, "-C", base, "package"], {
env: { ...process.env, COPYFILE_DISABLE: "1" },
});
const artifactSpec = action === "directory" ? `file:${source}` : archive;
const params = {
root,
installKind: "package" as const,
tag: artifactSpec,
installSpec: artifactSpec,
timeoutMs: 30_000,
startedAt: Date.now(),
progress: {},
installTarget: target,
nodeRunner: path.join(base, "not-the-selected-node"),
installEnv: {
...process.env,
npm_config_userconfig: path.join(base, "empty-user.npmrc"),
npm_config_globalconfig: path.join(base, "empty-global.npmrc"),
npm_config_cache: path.join(base, "npm-cache"),
},
managedServiceEnv: {
HOME: home,
OPENCLAW_STATE_DIR: canonicalState,
OPENCLAW_CONFIG_PATH: canonicalConfig,
},
};
let isolatedHome: string | undefined;
const initialization: Parameters<typeof runFreshUpdateArtifact>[0]["initialization"] = {
target: {
currentVersion: "1.0.0",
targetVersion: null,
packageTargetSchemaVersions: undefined,
packageRuntimeTarget: undefined,
downgradeRisk: false,
packageAlreadyCurrent: false,
refuseUpdate: async (_reason, message) => {
throw new Error(message);
},
},
};
await runFreshUpdateArtifact(
{ initialization, stageParams: () => params, json: true },
async () => {
const stage = initialization.stagedPackage;
assert(stage);
const paths = JSON.parse(
await fs.readFile(path.join(stage.root, "lifecycle-paths.json"), "utf8"),
);
isolatedHome = paths.openclawHome;
expect(paths.home).toBe(home);
expect(paths.state).not.toBe(canonicalState);
expect(await fs.readFile(path.join(paths.state, "lifecycle"), "utf8")).toBe("private");
expect(await fs.readFile(path.join(paths.tmp, "lifecycle"), "utf8")).toBe("private");
expect(initialization.target.targetVersion).toBe("1.0.1");
expect(await fs.readdir(canonicalState)).toEqual(["openclaw.json"]);
expect(await fs.readdir(canonicalTmp)).toEqual([]);
expect(await fs.readFile(canonicalConfig, "utf8")).toBe("{}\n");
if (action === "refuse") {
return;
}
let transaction: PackageUpdateTransaction | undefined;
const result = await stage.run({
...params,
nodeRunner: process.execPath,
validateCandidate: async (candidate) => {
expect(candidate).toBe(stage.root);
return [];
},
beforeActivate: async () => undefined,
onTransaction: (value) => {
transaction = value;
},
});
expect(result.status).toBe("ok");
expect(JSON.parse(await fs.readFile(canonicalConfig, "utf8"))).toEqual({
doctorState: canonicalState,
});
expect(transaction).toBeDefined();
await transaction!.complete({ activationVerified: true }, () => undefined);
},
);
expect(await fs.readdir(canonicalTmp)).toEqual([]);
expect(await fs.readdir(canonicalState)).toEqual(
action !== "refuse" ? ["openclaw.json", "openclaw.json.pre-update"] : ["openclaw.json"],
);
expect(isolatedHome).toBeDefined();
await expect(fs.stat(isolatedHome!)).rejects.toMatchObject({ code: "ENOENT" });
if (action === "refuse") {
expect(JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")).version).toBe(
"1.0.0",
);
}
});
},
60_000,
);
it("preserves an artifact refusal when private workspace cleanup also fails", async () => {
await withTestDir({ prefix: "artifact-cleanup-refusal-" }, async (base) => {
const control = path.join(base, "control");
await fs.mkdir(control, { mode: 0o700 });
vi.spyOn(tempRoot, "resolvePreferredOpenClawTmpDir").mockReturnValue(control);
const root = path.join(base, "candidate");
await writePackageRoot(root, "1.0.1");
await fs.writeFile(
path.join(root, "package.json"),
JSON.stringify({
name: "openclaw",
version: "1.0.1",
openclaw: { schemaVersions: { state: 17, agent: 19 } },
}),
);
const stage = { root, run: vi.fn(), close: vi.fn().mockResolvedValue(undefined) };
vi.spyOn(packageUpdate, "stagePackageInstallUpdate").mockResolvedValue(stage);
const primary = new Error("fixture original artifact refusal");
const cleanupFailure = new Error("fixture private workspace cleanup failed");
const createWorkspace = privateTempWorkspace.tempWorkspace;
const cleanup = vi.fn();
vi.spyOn(privateTempWorkspace, "tempWorkspace").mockImplementation(async (options) => {
const workspace = await createWorkspace(options);
return {
...workspace,
cleanup: async () => {
await workspace.cleanup();
cleanup();
throw cleanupFailure;
},
};
});
const initialization: Parameters<typeof runFreshUpdateArtifact>[0]["initialization"] = {
target: {
currentVersion: "1.0.0",
targetVersion: null,
packageTargetSchemaVersions: undefined,
packageRuntimeTarget: undefined,
downgradeRisk: false,
packageAlreadyCurrent: false,
refuseUpdate: async () => {
throw primary;
},
},
};
const outcome = await runFreshUpdateArtifact(
{
initialization,
json: true,
stageParams: () => ({
root,
installKind: "package",
tag: "file:/fixture/candidate.tgz",
timeoutMs: 1000,
startedAt: Date.now(),
progress: {},
installEnv: {},
}),
},
async () => {
throw primary;
},
).then(
() => undefined,
(error: unknown) => error,
);
expect(outcome).toBeInstanceOf(AggregateError);
expect(outcome instanceof AggregateError ? outcome.errors : []).toEqual([
primary,
cleanupFailure,
]);
expect(stage.close).toHaveBeenCalledOnce();
expect(cleanup).toHaveBeenCalledOnce();
});
});