mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-03 14:12:35 +00:00
fix(deps): give Baileys its long edge and materialize patched payloads (#177936)
Baileys imports long without declaring it, which a hoisted layout masked. An exact packageExtensions entry adds the edge. Candidate installs select pnpm's project virtual store so the patched Baileys bytes are materialized, and update preflight refreshes bundled plugin dependency links after Git exposure. Missing optional dependencies are skipped only when declared optional.
This commit is contained in:
parent
c431e55eeb
commit
432bada3bd
10 changed files with 403 additions and 4 deletions
|
|
@ -45,6 +45,19 @@ using that generation through finalization, rollback, and CLI cleanup, even when
|
|||
the new build has different chunk filenames. The new Gateway starts from the
|
||||
activated build.
|
||||
|
||||
Candidate pnpm installs use a project-local virtual dependency graph while
|
||||
reusing the configured package store. This also applies when pnpm uses hoisted
|
||||
links: pnpm 12 can otherwise materialize unpatched package bytes from a global
|
||||
graph despite recording a patch hash. After source exposure changes dependency
|
||||
topology, the updater refreshes bundled plugin links before final candidate
|
||||
validation. The refresh links the packages it finds in each plugin's
|
||||
`node_modules`. A link whose target is missing produces a warning and is skipped
|
||||
when the plugin declares that package optional; any other broken link or
|
||||
filesystem error stops preflight. The refresh does not check for dependencies
|
||||
that were never installed.
|
||||
These preparation changes require the installed updater to contain the fix;
|
||||
staging a newer candidate cannot change an older updater's initial install.
|
||||
|
||||
Managed update helpers route diagnostic logs to stderr so debug logging cannot
|
||||
interrupt their control handshake. This applies to the installed updater; a newer
|
||||
candidate cannot change an older updater's pre-activation handshake.
|
||||
|
|
|
|||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -209,7 +209,7 @@ overrides:
|
|||
protobufjs: 8.8.1
|
||||
uuid: 14.0.3
|
||||
|
||||
packageExtensionsChecksum: sha256-zZ8fyodhMTumshonC7kktCqTPsiHL3UAyS9vltFAlMo=
|
||||
packageExtensionsChecksum: sha256-+aaViXQWMTYboC/2zljN8mTDYzt1x2ZpXFjRkJd9Eys=
|
||||
|
||||
patchedDependencies:
|
||||
'@novnc/novnc@1.7.0': bfde0e1bda172da3525f9f5014d85ad28073325b74f09609084598a65d2f412d
|
||||
|
|
@ -15499,6 +15499,7 @@ snapshots:
|
|||
'@hapi/boom': 9.1.4
|
||||
async-mutex: 0.5.0
|
||||
libsignal: 6.0.0
|
||||
long: 5.3.2
|
||||
lru-cache: 11.5.3
|
||||
music-metadata: 11.16.1(supports-color@10.2.2)
|
||||
p-queue: 9.3.3
|
||||
|
|
|
|||
|
|
@ -130,6 +130,10 @@ allowBuilds:
|
|||
node-pty: true
|
||||
|
||||
packageExtensions:
|
||||
# rc14 imports long directly; protobufjs's transitive edge is not visible in isolated graphs.
|
||||
"baileys@7.0.0-rc14":
|
||||
dependencies:
|
||||
long: 5.3.2
|
||||
# The transient npm lock generator also needs the peer marked optional.
|
||||
baileys:
|
||||
peerDependenciesMeta:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ export function linkSourcePluginDependencies(pluginDir, distNodeModules) {
|
|||
if (!fs.existsSync(sourceModules)) {
|
||||
return;
|
||||
}
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf8"));
|
||||
const optional = new Set([
|
||||
...Object.keys(packageJson.optionalDependencies ?? {}),
|
||||
...Object.entries(packageJson.peerDependenciesMeta ?? {})
|
||||
.filter(([, metadata]) => metadata.optional === true)
|
||||
.map(([name]) => name),
|
||||
]);
|
||||
const packages = fs.readdirSync(sourceModules).flatMap((name) => {
|
||||
if (name.startsWith(".") && name !== ".bin") {
|
||||
return [];
|
||||
|
|
@ -26,7 +33,18 @@ export function linkSourcePluginDependencies(pluginDir, distNodeModules) {
|
|||
const checkoutRoot = fs.realpathSync(path.resolve(pluginDir, "../.."));
|
||||
for (const name of packages) {
|
||||
const target = path.join(distNodeModules, name);
|
||||
const source = fs.realpathSync(path.join(sourceModules, name));
|
||||
let source;
|
||||
try {
|
||||
source = fs.realpathSync(path.join(sourceModules, name));
|
||||
} catch (error) {
|
||||
if (optional.has(name) && error?.code === "ENOENT") {
|
||||
console.warn(
|
||||
`[bundled-plugin-dependency-links] ${path.basename(pluginDir)}: optional dependency ${name} is unavailable; continuing without its link`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const canonical =
|
||||
name === "openclaw" && source === checkoutRoot && fs.existsSync(hostAlias)
|
||||
? fs.realpathSync(hostAlias)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ export function resolvePnpmCandidateEnv(
|
|||
// Set every spelling: inherited lower-case keys can win over upper-case overrides.
|
||||
return {
|
||||
...env,
|
||||
// pnpm 12 selects the global graph independently of virtualStoreDir. Its
|
||||
// hoisted/global materializer can also emit unpatched registry bytes while
|
||||
// recording the patch hash. Candidates need the project materializer.
|
||||
PNPM_CONFIG_VIRTUAL_STORE_TYPE: "project",
|
||||
pnpm_config_virtual_store_type: "project",
|
||||
NPM_CONFIG_VIRTUAL_STORE_TYPE: "project",
|
||||
npm_config_virtual_store_type: "project",
|
||||
PNPM_CONFIG_VIRTUAL_STORE_DIR: virtualStoreDir,
|
||||
pnpm_config_virtual_store_dir: virtualStoreDir,
|
||||
NPM_CONFIG_VIRTUAL_STORE_DIR: virtualStoreDir,
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ export async function prepareCandidateCommandEnv(
|
|||
// Only the disposable worktree gets this override; retain all operator settings.
|
||||
const workspace = parseDocument(original);
|
||||
workspace.set("virtualStoreDir", "node_modules/.pnpm");
|
||||
workspace.set("virtualStoreType", "project");
|
||||
const isolated = workspace.toString();
|
||||
const backupDirectory = await fs.mkdtemp(path.join(path.dirname(cwd), "workspace-original-"));
|
||||
const backupFile = path.join(backupDirectory, "pnpm-workspace.yaml");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { CommandProcessCleanupError } from "../process/exec-result.js";
|
||||
|
|
@ -242,6 +243,110 @@ describe("reference source candidate preparation", () => {
|
|||
});
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
"refreshes final dependency links after exposure (corrupt: %s)",
|
||||
async (corrupt) => {
|
||||
const linker = pathToFileURL(
|
||||
path.resolve("scripts/lib/bundled-plugin-dependency-links.mjs"),
|
||||
).href;
|
||||
const script = "scripts/copy-bundled-plugin-metadata.mjs";
|
||||
await fs.mkdir(path.join(root, "scripts"));
|
||||
await fs.writeFile(
|
||||
path.join(root, script),
|
||||
[
|
||||
'import fs from "node:fs";',
|
||||
'import path from "node:path";',
|
||||
"import { linkSourcePluginDependencies } from " + JSON.stringify(linker) + ";",
|
||||
'const destination = path.resolve("dist/extensions/x/node_modules");',
|
||||
"fs.rmSync(destination, { recursive: true, force: true });",
|
||||
'linkSourcePluginDependencies(path.resolve("extensions/x"), destination);',
|
||||
].join("\n"),
|
||||
);
|
||||
await fs.mkdir(path.join(root, "extensions/x"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, "extensions/x/package.json"),
|
||||
JSON.stringify({ dependencies: { payload: "1" } }),
|
||||
);
|
||||
await git(root, "add", script, "extensions/x/package.json");
|
||||
await git(root, "commit", "-m", "candidate metadata entrypoint");
|
||||
base = await git(root, "rev-parse", "HEAD");
|
||||
let built = false;
|
||||
let exposed = false;
|
||||
let validated = false;
|
||||
const runCommand: CommandRunner = async (argv, options) => {
|
||||
if (argv[0] === "pnpm") {
|
||||
if (argv[1] === "build") {
|
||||
assert(options.cwd);
|
||||
const candidate = options.cwd;
|
||||
for (const directory of [
|
||||
"dist/control-ui",
|
||||
"extensions/x/node_modules",
|
||||
"node_modules/first",
|
||||
]) {
|
||||
await fs.mkdir(path.join(candidate, directory), { recursive: true });
|
||||
}
|
||||
await writeFixtureFiles(candidate, {
|
||||
"dist/control-ui/index.html": "ready",
|
||||
"node_modules/first/index.js": "module.exports = 'old';",
|
||||
});
|
||||
await fs.symlink(
|
||||
path.join(candidate, "node_modules/first"),
|
||||
path.join(candidate, "extensions/x/node_modules/payload"),
|
||||
"junction",
|
||||
);
|
||||
const linked = await runCommandWithTimeout(["node", script], {
|
||||
...options,
|
||||
cwd: candidate,
|
||||
});
|
||||
expect(linked.code, linked.stderr).toBe(0);
|
||||
built = true;
|
||||
}
|
||||
return { code: 0, stdout: argv[1] === "--version" ? "12.0.0" : "", stderr: "" };
|
||||
}
|
||||
return runCommandWithTimeout(argv, options);
|
||||
};
|
||||
const result = await prepare(runCommand, {
|
||||
prepareGitExposure: async (candidate) => {
|
||||
expect(built).toBe(true);
|
||||
await expectHead(root, base);
|
||||
const link = path.join(candidate, "extensions/x/node_modules/payload");
|
||||
await fs.rm(link);
|
||||
await fs.rm(path.join(candidate, "node_modules/first"), { recursive: true });
|
||||
if (corrupt) {
|
||||
await fs.symlink("payload", link);
|
||||
} else {
|
||||
await fs.mkdir(path.join(candidate, "node_modules/final"), { recursive: true });
|
||||
await writeFixtureFiles(candidate, {
|
||||
"node_modules/final/index.js": "module.exports = 'final';",
|
||||
});
|
||||
await fs.symlink(path.join(candidate, "node_modules/final"), link, "junction");
|
||||
}
|
||||
exposed = true;
|
||||
},
|
||||
validateCandidate: async (candidate) => {
|
||||
expect(exposed).toBe(true);
|
||||
const imported = await runCommandWithTimeout(
|
||||
[
|
||||
"node",
|
||||
"-e",
|
||||
'require("node:assert/strict").equal(require(require("node:path").resolve("dist/extensions/x/node_modules/payload")), "final");',
|
||||
],
|
||||
{ cwd: candidate, timeoutMs: 5000 },
|
||||
);
|
||||
expect(imported.code, imported.stderr).toBe(0);
|
||||
validated = true;
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(corrupt ? "error" : "ok");
|
||||
expect(validated).toBe(!corrupt);
|
||||
expectStep(steps, {
|
||||
name: "preflight-bundled-plugin-dependency-links",
|
||||
exitCode: corrupt ? 1 : 0,
|
||||
});
|
||||
await expectHead(root, base);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses divergent main before install or copying operator inputs", async () => {
|
||||
const beforeSha = await commit("local.txt", "local\n");
|
||||
const targetRevision = await commitTarget("target.txt", "target\n");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
||||
import { hasCommandProcessCleanupError } from "../process/exec-result.js";
|
||||
import { resolveControlUiAssetHealth } from "./control-ui-assets.js";
|
||||
import { hasErrnoCode } from "./errno.js";
|
||||
import { hasErrnoCode, ignoreMissingPath } from "./errno.js";
|
||||
import { formatErrorMessage } from "./errors.js";
|
||||
import { readPackageManagerSpec } from "./package-json.js";
|
||||
import { DEV_BRANCH } from "./update-channels.js";
|
||||
|
|
@ -330,6 +331,24 @@ async function testPreflightCandidate(
|
|||
// Global source exposure can run package lifecycle scripts. Validate and retain
|
||||
// the resulting candidate only after that preparation finishes.
|
||||
await params.prepareGitExposure?.(params.worktreeDir, candidateSha, candidateCommand.env);
|
||||
// Exposure can run another install after postbuild. Its final topology, not
|
||||
// the earlier build graph, must own the packaged plugin dependency links.
|
||||
const metadataScript = "scripts/copy-bundled-plugin-metadata.mjs";
|
||||
if (
|
||||
params.prepareGitExposure &&
|
||||
(
|
||||
await fs.stat(path.join(params.worktreeDir, metadataScript)).catch(ignoreMissingPath)
|
||||
)?.isFile()
|
||||
) {
|
||||
const relink = await runCandidateCheck(
|
||||
"bundled-plugin-dependency-links",
|
||||
["node", metadataScript],
|
||||
candidateCommand.env,
|
||||
);
|
||||
if (relink) {
|
||||
return { status: classifyPreflightFailure(relink) };
|
||||
}
|
||||
}
|
||||
await candidateCommand.restoreWorkspace?.();
|
||||
params.steps.push(...((await params.validateCandidate(params.worktreeDir)) ?? []));
|
||||
// Activation checks out candidateSha and promotes only generated runtime paths.
|
||||
|
|
|
|||
174
test/scripts/baileys-materialization.test.ts
Normal file
174
test/scripts/baileys-materialization.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, it } from "vitest";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { copyBundledPluginMetadata } from "../../scripts/copy-bundled-plugin-metadata.mts";
|
||||
import { pnpmLockfileDocuments } from "../../scripts/lib/pnpm-lockfile-documents.mjs";
|
||||
import { prepareCandidateCommandEnv } from "../../src/infra/update-runner-git-commands.js";
|
||||
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir } = createScriptTestHarness();
|
||||
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
|
||||
|
||||
it("materializes patched Baileys with its own long edge and refreshes final dist imports", async () => {
|
||||
// Reuse the installed CAFS packages offline, never its mutable graph/payloads.
|
||||
const root = fs.realpathSync(createTempDir("openclaw-baileys-materialization-"));
|
||||
const workspace = parse(fs.readFileSync(path.join(repoRoot, "pnpm-workspace.yaml"), "utf8"));
|
||||
const documents = pnpmLockfileDocuments(
|
||||
fs.readFileSync(path.join(repoRoot, "pnpm-lock.yaml"), "utf8"),
|
||||
);
|
||||
const lock = parse(documents.dependencies);
|
||||
const installed = parse(
|
||||
fs.readFileSync(path.join(repoRoot, "node_modules/.modules.yaml"), "utf8"),
|
||||
);
|
||||
const packageManager = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"),
|
||||
).packageManager;
|
||||
const whatsapp = lock.importers["extensions/whatsapp"].dependencies;
|
||||
const dependencies: Record<string, { specifier: string; version: string }> = {
|
||||
baileys: whatsapp.baileys,
|
||||
"audio-decode": whatsapp["audio-decode"],
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
private: true,
|
||||
type: "module",
|
||||
packageManager,
|
||||
dependencies: Object.fromEntries(
|
||||
Object.entries(dependencies).map(([name, entry]) => [name, entry.specifier]),
|
||||
),
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "pnpm-lock.yaml"),
|
||||
"---\n" +
|
||||
documents.environment +
|
||||
"\n---\n" +
|
||||
stringify({ ...lock, importers: { ".": { dependencies } } }),
|
||||
);
|
||||
for (const relative of Object.values(workspace.patchedDependencies) as string[]) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, relative)), { recursive: true });
|
||||
fs.copyFileSync(path.join(repoRoot, relative), path.join(root, relative));
|
||||
}
|
||||
Object.assign(workspace, {
|
||||
packages: ["."],
|
||||
hoist: false,
|
||||
virtualStoreType: "global",
|
||||
// This offline fixture tests pinned payloads, not registry-age metadata.
|
||||
minimumReleaseAge: 0,
|
||||
minimumReleaseAgeStrict: false,
|
||||
packageImportMethod: "auto",
|
||||
storeDir: path.dirname(installed.storeDir),
|
||||
});
|
||||
const env = {
|
||||
...process.env,
|
||||
CI: undefined,
|
||||
NODE_OPTIONS: undefined,
|
||||
NODE_PATH: undefined,
|
||||
HOME: path.join(root, "home"),
|
||||
PNPM_CONFIG_VIRTUAL_STORE_TYPE: "global",
|
||||
pnpm_config_virtual_store_type: "global",
|
||||
};
|
||||
const command = async (argv: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv }) => {
|
||||
assert(argv[0], "fixture command requires an executable");
|
||||
const result = spawnSync(argv[0], argv.slice(1), {
|
||||
...options,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return { code: result.status, stdout: result.stdout, stderr: result.stderr };
|
||||
};
|
||||
const plugin = path.join(root, "extensions/whatsapp");
|
||||
fs.mkdirSync(path.join(plugin, "node_modules"), { recursive: true });
|
||||
fs.writeFileSync(path.join(plugin, "index.ts"), "export {};\n");
|
||||
fs.writeFileSync(
|
||||
path.join(plugin, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@openclaw/whatsapp",
|
||||
type: "module",
|
||||
dependencies: { baileys: "7.0.0-rc14" },
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
build: { bundledDist: false },
|
||||
release: { publishToNpm: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(plugin, "openclaw.plugin.json"),
|
||||
JSON.stringify({ id: "whatsapp", configSchema: { type: "object" } }),
|
||||
);
|
||||
const distPlugin = path.join(root, "dist/extensions/whatsapp");
|
||||
const sibling = path.join(root, "dist/extensions/sibling/index.js");
|
||||
fs.mkdirSync(path.dirname(sibling), { recursive: true });
|
||||
fs.writeFileSync(sibling, "preserve sibling");
|
||||
let previousTarget: string | undefined;
|
||||
for (const nodeLinker of ["isolated", "hoisted"]) {
|
||||
workspace.nodeLinker = nodeLinker;
|
||||
const original = stringify(workspace);
|
||||
fs.writeFileSync(path.join(root, "pnpm-workspace.yaml"), original);
|
||||
const prepared = await prepareCandidateCommandEnv("pnpm", env, root, command, 30_000);
|
||||
try {
|
||||
const install = await command(
|
||||
["pnpm", "install", "--offline", "--ignore-scripts", "--frozen-lockfile"],
|
||||
{ cwd: root, env: prepared.env },
|
||||
);
|
||||
expect(install.code, install.stderr + install.stdout).toBe(0);
|
||||
const source = fs.realpathSync(path.join(root, "node_modules/baileys"));
|
||||
const sourceLink = path.join(plugin, "node_modules/baileys");
|
||||
fs.rmSync(sourceLink, { force: true });
|
||||
fs.symlinkSync(source, sourceLink, "junction");
|
||||
if (previousTarget) {
|
||||
// An install changed topology after postbuild. The prior dist link is stale.
|
||||
expect(source).not.toBe(previousTarget);
|
||||
}
|
||||
copyBundledPluginMetadata({ repoRoot: root, pluginIds: new Set(["whatsapp"]) });
|
||||
const probe = await command(
|
||||
[
|
||||
resolveTestNodeExecPath(),
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
`import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
const require = createRequire(process.cwd() + "/probe.cjs");
|
||||
const entry = require.resolve("baileys");
|
||||
const baileysRequire = createRequire(entry);
|
||||
assert.equal(baileysRequire("long").fromString("9007199254740993").toString(), "9007199254740993");
|
||||
const { getAudioWaveform } = await import(entry);
|
||||
const wav = Buffer.alloc(44 + 4096 * 2);
|
||||
wav.write("RIFF"); wav.writeUInt32LE(wav.length - 8, 4); wav.write("WAVEfmt ", 8);
|
||||
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
|
||||
wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28);
|
||||
wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34); wav.write("data", 36);
|
||||
wav.writeUInt32LE(wav.length - 44, 40);
|
||||
for (let i = 0; i < 4096; i++) wav.writeInt16LE(Math.round(16000 * Math.sin(i / 10)), 44 + i * 2);
|
||||
const waveform = await getAudioWaveform(wav);
|
||||
assert.equal(waveform?.length, 64, "patched decoder must produce the voice-note waveform");
|
||||
assert.ok([...waveform].some(value => value > 0));
|
||||
assert.equal(await getAudioWaveform(Buffer.from("not audio")), undefined);
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL("../package.json", pathToFileURL(entry))));
|
||||
assert.equal(manifest.peerDependencies["audio-decode"], "^3.12.0");
|
||||
console.log("patched waveform: 64 samples; invalid audio: best-effort undefined");`,
|
||||
],
|
||||
{ cwd: distPlugin, env: prepared.env },
|
||||
);
|
||||
expect(probe.code, probe.stderr + probe.stdout).toBe(0);
|
||||
expect(probe.stdout).toContain("patched waveform: 64 samples");
|
||||
expect(fs.readFileSync(sibling, "utf8")).toBe("preserve sibling");
|
||||
previousTarget = source;
|
||||
} finally {
|
||||
await prepared.restoreWorkspace?.();
|
||||
}
|
||||
expect(fs.readFileSync(path.join(root, "pnpm-workspace.yaml"), "utf8")).toBe(original);
|
||||
}
|
||||
}, 60_000);
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { linkSourcePluginDependencies } from "../../scripts/lib/bundled-plugin-dependency-links.mjs";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ describe("linkSourcePluginDependencies", () => {
|
|||
const destination = path.join(root, "dist", "extensions", "x", "node_modules");
|
||||
const host = checkoutHost ? root : path.join(root, "installed", "openclaw");
|
||||
fs.mkdirSync(modules, { recursive: true });
|
||||
fs.writeFileSync(path.join(plugin, "package.json"), "{}");
|
||||
for (const [name, target] of [
|
||||
["openclaw", host],
|
||||
["ancestor-alias", root],
|
||||
|
|
@ -25,7 +27,14 @@ describe("linkSourcePluginDependencies", () => {
|
|||
fs.symlinkSync(target, path.join(modules, name), "junction");
|
||||
}
|
||||
fs.writeFileSync(path.join(root, "payload", "index.cjs"), "module.exports = 'dependency';");
|
||||
fs.writeFileSync(
|
||||
path.join(root, "payload", "package.json"),
|
||||
JSON.stringify({ main: "index.cjs" }),
|
||||
);
|
||||
linkSourcePluginDependencies(plugin, destination);
|
||||
expect(createRequire(path.join(destination, "..", "probe.cjs"))("@fixture/dependency")).toBe(
|
||||
"dependency",
|
||||
);
|
||||
expect(fs.existsSync(path.join(destination, "openclaw"))).toBe(!checkoutHost);
|
||||
if (!checkoutHost) {
|
||||
expect(fs.realpathSync(path.join(destination, "openclaw"))).toBe(host);
|
||||
|
|
@ -36,4 +45,52 @@ describe("linkSourcePluginDependencies", () => {
|
|||
);
|
||||
expect(fs.realpathSync(path.join(destination, ".bin"))).toBe(path.join(root, "launchers"));
|
||||
});
|
||||
it.each(["optionalDependencies", "peerDependenciesMeta"])(
|
||||
"warns for an unavailable declared optional dependency (%s), preserving usable siblings",
|
||||
(field) => {
|
||||
const root = fs.realpathSync(createTempDir("openclaw-optional-dependency-link-"));
|
||||
const plugin = path.join(root, "extensions/x");
|
||||
const modules = path.join(plugin, "node_modules");
|
||||
const destination = path.join(root, "dist/extensions/x/node_modules");
|
||||
fs.mkdirSync(modules, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(plugin, "package.json"),
|
||||
JSON.stringify({
|
||||
[field]: { missing: field === "optionalDependencies" ? "1.0.0" : { optional: true } },
|
||||
}),
|
||||
);
|
||||
fs.symlinkSync(path.join(root, "absent"), path.join(modules, "missing"), "junction");
|
||||
fs.mkdirSync(path.join(modules, "usable"));
|
||||
fs.writeFileSync(path.join(modules, "usable/index.js"), "module.exports = 'usable';");
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
linkSourcePluginDependencies(plugin, destination);
|
||||
expect(createRequire(path.join(destination, "..", "probe.cjs"))("usable")).toBe("usable");
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("optional dependency missing is unavailable"),
|
||||
);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
// The same missing payload is not optional without a manifest declaration.
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.writeFileSync(path.join(plugin, "package.json"), "{}");
|
||||
expect(() => linkSourcePluginDependencies(plugin, destination)).toThrow(/ENOENT/);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not hide unknown filesystem corruption for optional dependencies", () => {
|
||||
const root = fs.realpathSync(createTempDir("openclaw-invalid-dependency-link-"));
|
||||
const plugin = path.join(root, "extensions/x");
|
||||
const modules = path.join(plugin, "node_modules");
|
||||
fs.mkdirSync(modules, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(plugin, "package.json"),
|
||||
JSON.stringify({ optionalDependencies: { cycle: "1" } }),
|
||||
);
|
||||
fs.symlinkSync("cycle", path.join(modules, "cycle"));
|
||||
expect(() =>
|
||||
linkSourcePluginDependencies(plugin, path.join(root, "dist/extensions/x/node_modules")),
|
||||
).toThrow(/ELOOP/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue