447 lines
17 KiB
JavaScript
447 lines
17 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { readFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import { createRequire } from "node:module";
|
|
import { runInNewContext } from "node:vm";
|
|
|
|
import cliEsbuildConfig from "../cli/esbuild.config.mjs";
|
|
import { bundledCliNpmDependencies } from "./cli-bundled-npm-dependencies.mjs";
|
|
import {
|
|
createBundledInstallManifest,
|
|
materializePublishManifest,
|
|
selectBundledDependencyPatches,
|
|
} from "./prepare-bundled-package.mjs";
|
|
|
|
const rootPackage = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
const adapterUtilsPackage = JSON.parse(
|
|
await readFile(new URL("../packages/adapter-utils/package.json", import.meta.url), "utf8"),
|
|
);
|
|
const runnerPackage = JSON.parse(
|
|
await readFile(new URL("../packages/paperclip-runner/package.json", import.meta.url), "utf8"),
|
|
);
|
|
const serverPackage = JSON.parse(
|
|
await readFile(new URL("../server/package.json", import.meta.url), "utf8"),
|
|
);
|
|
const dbPackage = JSON.parse(
|
|
await readFile(new URL("../packages/db/package.json", import.meta.url), "utf8"),
|
|
);
|
|
const releaseScript = await readFile(new URL("./release.sh", import.meta.url), "utf8");
|
|
const releaseLib = await readFile(new URL("./release-lib.sh", import.meta.url), "utf8");
|
|
const buildNpmScript = await readFile(new URL("./build-npm.sh", import.meta.url), "utf8");
|
|
const acpxRuntimePatch = await readFile(
|
|
new URL("../patches/acpx@0.13.1.patch", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const claudeAcpPatch = await readFile(
|
|
new URL("../patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
for (const version of ["0.12.0", "0.13.1"]) {
|
|
test(`ACPX ${version} release patch uses portable generated unified hunks`, async () => {
|
|
const patch = await readFile(
|
|
new URL(`../patches/acpx@${version}.patch`, import.meta.url),
|
|
"utf8",
|
|
);
|
|
const lines = patch.split("\n");
|
|
let hunkCount = 0;
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const header = lines[index].match(
|
|
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/,
|
|
);
|
|
if (!header) continue;
|
|
hunkCount += 1;
|
|
const body = [];
|
|
let oldLines = 0;
|
|
let newLines = 0;
|
|
while (index + 1 < lines.length && (oldLines < Number(header[2] ?? 1) || newLines < Number(header[4] ?? 1))) {
|
|
const line = lines[++index];
|
|
// Unified diff's EOF marker is metadata, not a source/destination
|
|
// line, and may occur between the removed and added final lines.
|
|
if (line === "\\ No newline at end of file") continue;
|
|
// Git accepts an empty context line with its optional space omitted.
|
|
assert.ok(line === "" || /^[ +\-]/.test(line), `invalid unified hunk line: ${line}`);
|
|
const normalized = line === "" ? " " : line;
|
|
body.push(normalized);
|
|
if (!normalized.startsWith("+")) oldLines += 1;
|
|
if (!normalized.startsWith("-")) newLines += 1;
|
|
}
|
|
assert.equal(
|
|
body.filter((line) => !line.startsWith("+")).length,
|
|
Number(header[2] ?? 1),
|
|
);
|
|
assert.equal(
|
|
body.filter((line) => !line.startsWith("-")).length,
|
|
Number(header[4] ?? 1),
|
|
);
|
|
const prefix = body.findIndex((line) => !line.startsWith(" "));
|
|
const suffix = body
|
|
.slice()
|
|
.reverse()
|
|
.findIndex((line) => !line.startsWith(" "));
|
|
// pnpm patch-commit emits three context lines. Hand-added asymmetric
|
|
// context can force GNU patch's locate_hunk() to require EOF even when
|
|
// BSD patch and git apply accept the same source and hunk.
|
|
assert.ok(
|
|
prefix >= 0 && prefix <= 3,
|
|
`regenerate ${version} hunk at old line ${header[1]} with pnpm patch-commit (prefix ${prefix})`,
|
|
);
|
|
assert.ok(
|
|
suffix >= 0 && suffix <= 3,
|
|
`regenerate ${version} hunk at old line ${header[1]} with pnpm patch-commit (suffix ${suffix})`,
|
|
);
|
|
}
|
|
assert.ok(hunkCount > 0);
|
|
});
|
|
}
|
|
|
|
test("published packages preserve the patched ACPX runtime", () => {
|
|
assert.equal(
|
|
rootPackage.pnpm.patchedDependencies["acpx@0.12.0"],
|
|
"patches/acpx@0.12.0.patch",
|
|
);
|
|
assert.equal(
|
|
rootPackage.pnpm.patchedDependencies["acpx@0.13.1"],
|
|
"patches/acpx@0.13.1.patch",
|
|
);
|
|
assert.equal(adapterUtilsPackage.dependencies.acpx, "0.12.0");
|
|
assert.deepEqual(adapterUtilsPackage.bundleDependencies, ["acpx"]);
|
|
assert.equal(serverPackage.dependencies.acpx, "0.13.1");
|
|
assert.ok(serverPackage.bundleDependencies.includes("acpx"));
|
|
assert.equal(bundledCliNpmDependencies.has("acpx"), true);
|
|
assert.equal(cliEsbuildConfig.external.includes("acpx"), false);
|
|
});
|
|
|
|
test("Paperclip Runner pins the qualified ACPX host callbacks", () => {
|
|
assert.equal(rootPackage.pnpm.patchedDependencies["acpx@0.13.1"], "patches/acpx@0.13.1.patch");
|
|
assert.equal(
|
|
rootPackage.pnpm.patchedDependencies["@agentclientprotocol/claude-agent-acp@0.73.0"],
|
|
"patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch",
|
|
);
|
|
assert.equal(runnerPackage.dependencies.acpx, "0.13.1");
|
|
assert.equal(runnerPackage.dependencies["@agentclientprotocol/claude-agent-acp"], "0.73.0");
|
|
assert.equal(runnerPackage.dependencies["@agentclientprotocol/codex-acp"], "1.6.2");
|
|
for (const callback of [
|
|
"spawnEnvironment", "spawnCwd", "spawnAgent", "isPlainStringEnvironment",
|
|
"onAgentSpawn", "onAgentStderr", "onAgentExit",
|
|
"onSessionNotification", "onClientOperation",
|
|
]) assert.match(acpxRuntimePatch, new RegExp(callback));
|
|
assert.match(claudeAcpPatch, /usage: \{/);
|
|
assert.match(claudeAcpPatch, /cache_creation_input_tokens/);
|
|
});
|
|
|
|
test("published packages preserve the patched embedded-postgres runtime", () => {
|
|
assert.equal(
|
|
rootPackage.pnpm.patchedDependencies["embedded-postgres@18.1.0-beta.16"],
|
|
"patches/embedded-postgres@18.1.0-beta.16.patch",
|
|
);
|
|
assert.deepEqual(dbPackage.bundleDependencies, ["embedded-postgres"]);
|
|
assert.equal(bundledCliNpmDependencies.has("embedded-postgres"), true);
|
|
assert.equal(cliEsbuildConfig.external.includes("embedded-postgres"), false);
|
|
});
|
|
|
|
test("bundled package staging materializes publishConfig entrypoints", () => {
|
|
const staged = materializePublishManifest(adapterUtilsPackage);
|
|
|
|
assert.equal(staged.publishConfig, undefined);
|
|
assert.equal(staged.main, "./dist/index.js");
|
|
assert.equal(staged.types, "./dist/index.d.ts");
|
|
assert.deepEqual(staged.exports, adapterUtilsPackage.publishConfig.exports);
|
|
});
|
|
|
|
test("bundled package staging materializes workspace dependency versions", () => {
|
|
const staged = materializePublishManifest({
|
|
name: "@paperclipai/example",
|
|
version: "2026.723.0",
|
|
dependencies: { exact: "workspace:*", caret: "workspace:^", tilde: "workspace:~" },
|
|
});
|
|
|
|
assert.deepEqual(staged.dependencies, {
|
|
exact: "2026.723.0",
|
|
caret: "^2026.723.0",
|
|
tilde: "~2026.723.0",
|
|
});
|
|
});
|
|
|
|
test("bundled package staging installs only dependencies included in the tarball", () => {
|
|
const publishManifest = {
|
|
name: "@paperclipai/db",
|
|
version: "2026.723.0-canary.8",
|
|
dependencies: {
|
|
"@paperclipai/shared": "2026.723.0-canary.8",
|
|
"drizzle-orm": "^0.45.2",
|
|
"embedded-postgres": "^18.1.0-beta.16",
|
|
},
|
|
devDependencies: {
|
|
"@paperclipai/paperclip-runner": "2026.723.0-canary.8",
|
|
},
|
|
bundleDependencies: ["embedded-postgres"],
|
|
};
|
|
const installManifest = createBundledInstallManifest(publishManifest, ["embedded-postgres"]);
|
|
|
|
assert.deepEqual(installManifest.dependencies, {
|
|
"embedded-postgres": "^18.1.0-beta.16",
|
|
});
|
|
assert.equal(installManifest.devDependencies, undefined);
|
|
assert.deepEqual(publishManifest.devDependencies, {
|
|
"@paperclipai/paperclip-runner": "2026.723.0-canary.8",
|
|
});
|
|
assert.deepEqual(installManifest.bundleDependencies, ["embedded-postgres"]);
|
|
});
|
|
|
|
test("bundled package staging selects only the installed dependency version's patch", (t) => {
|
|
const destinationDir = mkdtempSync(join(tmpdir(), "paperclip-bundled-patch-selection-"));
|
|
const installedPackageDir = join(destinationDir, "node_modules", "acpx");
|
|
mkdirSync(installedPackageDir, { recursive: true });
|
|
writeFileSync(
|
|
join(installedPackageDir, "package.json"),
|
|
JSON.stringify({ name: "acpx", version: "0.12.0" }),
|
|
);
|
|
t.after(() => rmSync(destinationDir, { recursive: true, force: true }));
|
|
|
|
assert.deepEqual(
|
|
selectBundledDependencyPatches(destinationDir, ["acpx"], {
|
|
"acpx@0.12.0": "patches/acpx@0.12.0.patch",
|
|
"acpx@0.13.1": "patches/acpx@0.13.1.patch",
|
|
}),
|
|
[
|
|
{
|
|
packageName: "acpx",
|
|
specifier: "acpx@0.12.0",
|
|
patchPath: "patches/acpx@0.12.0.patch",
|
|
},
|
|
],
|
|
);
|
|
});
|
|
|
|
test("bundled package patch selection handles scoped package names", (t) => {
|
|
const destinationDir = mkdtempSync(join(tmpdir(), "paperclip-scoped-patch-selection-"));
|
|
const installedPackageDir = join(destinationDir, "node_modules", "@example", "runtime");
|
|
mkdirSync(installedPackageDir, { recursive: true });
|
|
writeFileSync(
|
|
join(installedPackageDir, "package.json"),
|
|
JSON.stringify({ name: "@example/runtime", version: "1.2.3" }),
|
|
);
|
|
t.after(() => rmSync(destinationDir, { recursive: true, force: true }));
|
|
|
|
assert.deepEqual(
|
|
selectBundledDependencyPatches(destinationDir, ["@example/runtime"], {
|
|
"@example/runtime@1.2.3": "patches/runtime@1.2.3.patch",
|
|
"@example/runtime@2.0.0": "patches/runtime@2.0.0.patch",
|
|
}),
|
|
[
|
|
{
|
|
packageName: "@example/runtime",
|
|
specifier: "@example/runtime@1.2.3",
|
|
patchPath: "patches/runtime@1.2.3.patch",
|
|
},
|
|
],
|
|
);
|
|
});
|
|
|
|
test("bundled package patch selection reports missing installed metadata", (t) => {
|
|
const destinationDir = mkdtempSync(join(tmpdir(), "paperclip-missing-patch-metadata-"));
|
|
t.after(() => rmSync(destinationDir, { recursive: true, force: true }));
|
|
|
|
assert.throws(
|
|
() =>
|
|
selectBundledDependencyPatches(destinationDir, ["acpx"], {
|
|
"acpx@0.12.0": "patches/acpx@0.12.0.patch",
|
|
}),
|
|
/Cannot select a patch for bundled dependency acpx: failed to read/,
|
|
);
|
|
});
|
|
|
|
test("bundled package patch selection rejects an unpatched installed version", (t) => {
|
|
const destinationDir = mkdtempSync(join(tmpdir(), "paperclip-unmatched-patch-version-"));
|
|
const installedPackageDir = join(destinationDir, "node_modules", "acpx");
|
|
mkdirSync(installedPackageDir, { recursive: true });
|
|
writeFileSync(
|
|
join(installedPackageDir, "package.json"),
|
|
JSON.stringify({ name: "acpx", version: "0.14.0" }),
|
|
);
|
|
t.after(() => rmSync(destinationDir, { recursive: true, force: true }));
|
|
|
|
assert.throws(
|
|
() =>
|
|
selectBundledDependencyPatches(destinationDir, ["acpx"], {
|
|
"acpx@0.12.0": "patches/acpx@0.12.0.patch",
|
|
"acpx@0.13.1": "patches/acpx@0.13.1.patch",
|
|
}),
|
|
/installed acpx@0\.14\.0, but configured patches are acpx@0\.12\.0, acpx@0\.13\.1/,
|
|
);
|
|
});
|
|
|
|
test("server package staging applies every bundled runtime patch and preserves the vendored runner", (t) => {
|
|
const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-bundled-stage-"));
|
|
const sourceDir = join(fixtureDir, "source");
|
|
const destinationDir = join(fixtureDir, "destination");
|
|
const binDir = join(fixtureDir, "bin");
|
|
const callLog = join(fixtureDir, "calls.log");
|
|
mkdirSync(sourceDir);
|
|
mkdirSync(join(sourceDir, "dist"));
|
|
writeFileSync(join(sourceDir, "dist", "index.js"), "export {};\n");
|
|
mkdirSync(destinationDir);
|
|
mkdirSync(binDir);
|
|
writeFileSync(
|
|
join(sourceDir, "package.json"),
|
|
JSON.stringify({ ...serverPackage, files: ["dist"] }),
|
|
);
|
|
writeFileSync(callLog, "");
|
|
t.after(() => rmSync(fixtureDir, { recursive: true, force: true }));
|
|
|
|
const writeExecutable = (name, body) => {
|
|
writeFileSync(join(binDir, name), body, { mode: 0o755 });
|
|
};
|
|
writeExecutable(
|
|
"pnpm",
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
printf 'pnpm %s\\n' "$*" >> "$FAKE_CALL_LOG"
|
|
destination="\${!#}"
|
|
cp "$FAKE_SOURCE_PACKAGE" "$destination/package.json"
|
|
mkdir -p "$destination/node_modules/.pnpm"
|
|
`,
|
|
);
|
|
writeExecutable(
|
|
"npm",
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
printf 'npm %s\\n' "$*" >> "$FAKE_CALL_LOG"
|
|
[ "$*" = "install --omit=dev --ignore-scripts --no-audit --no-fund" ]
|
|
node -e 'const fs = require("node:fs"); const pkg = require("./package.json"); if ("devDependencies" in pkg) process.exit(1); for (const [name, version] of Object.entries(pkg.dependencies)) { const dir = "node_modules/" + name; fs.mkdirSync(dir + "/dist", { recursive: true }); fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version })); }'
|
|
mkdir -p node_modules/acpx/dist
|
|
printf 'unpatched runtime\\n' > node_modules/acpx/dist/runtime.js
|
|
printf '{"name":"acpx","version":"0.13.1"}\\n' > node_modules/acpx/package.json
|
|
`,
|
|
);
|
|
writeExecutable(
|
|
"patch",
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
printf 'patch %s\\n' "$*" >> "$FAKE_CALL_LOG"
|
|
target=""
|
|
while [ "$#" -gt 0 ]; do
|
|
if [ "$1" = "-d" ]; then
|
|
target="$2"
|
|
shift 2
|
|
else
|
|
shift
|
|
fi
|
|
done
|
|
patch_input="$(cat)"
|
|
printf '%s\\n' "$patch_input" > "$target/applied.patch"
|
|
if [[ "$target" != */acpx ]]; then
|
|
exit 0
|
|
fi
|
|
grep -q spawnEnvironment <<< "$patch_input"
|
|
grep -q spawnAgent <<< "$patch_input"
|
|
grep -q onAgentStderr <<< "$patch_input"
|
|
printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js"
|
|
`,
|
|
);
|
|
|
|
execFileSync(
|
|
process.execPath,
|
|
[
|
|
new URL("./prepare-bundled-package.mjs", import.meta.url).pathname,
|
|
sourceDir,
|
|
destinationDir,
|
|
],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
PATH: `${binDir}:${process.env.PATH}`,
|
|
FAKE_CALL_LOG: callLog,
|
|
FAKE_SOURCE_PACKAGE: join(sourceDir, "package.json"),
|
|
},
|
|
stdio: "pipe",
|
|
},
|
|
);
|
|
|
|
const stagedAcpxDir = join(destinationDir, "node_modules/acpx");
|
|
assert.equal(lstatSync(stagedAcpxDir).isDirectory(), true);
|
|
assert.equal(lstatSync(stagedAcpxDir).isSymbolicLink(), false);
|
|
assert.equal(existsSync(join(destinationDir, "node_modules/.pnpm")), false);
|
|
assert.match(
|
|
readFileSync(join(stagedAcpxDir, "dist/runtime.js"), "utf8"),
|
|
/spawnEnvironment/,
|
|
);
|
|
assert.match(
|
|
readFileSync(callLog, "utf8"),
|
|
/patch -p1 --forward -d .*node_modules\/acpx/,
|
|
);
|
|
assert.equal(
|
|
readFileSync(callLog, "utf8")
|
|
.split("\n")
|
|
.filter((line) => line.startsWith("patch ")).length,
|
|
serverPackage.bundleDependencies.length,
|
|
);
|
|
for (const name of serverPackage.bundleDependencies) {
|
|
const specifier = `${name}@${serverPackage.dependencies[name]}`;
|
|
const patchPath = rootPackage.pnpm.patchedDependencies[specifier];
|
|
assert.equal(
|
|
readFileSync(
|
|
join(destinationDir, "node_modules", name, "applied.patch"),
|
|
"utf8",
|
|
),
|
|
`${readFileSync(new URL(`../${patchPath}`, import.meta.url), "utf8").trimEnd()}\n`,
|
|
`${specifier} receives its own full configured patch`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("bundled package dry runs preview without querying published versions", () => {
|
|
assert.match(releaseScript, /run_bundled_npm_pack pack --pack-destination "\$publish_dir"/);
|
|
assert.match(releaseLib, /BUNDLED_NPM_PACK_VERSION="10\.9\.7"/);
|
|
assert.match(releaseLib, /BUNDLED_NPM_PUBLISH_VERSION="11\.18\.0"/);
|
|
assert.match(
|
|
releaseLib,
|
|
/npx --yes "npm@\$BUNDLED_NPM_PACK_VERSION" "\$@" --ignore-scripts/,
|
|
);
|
|
assert.match(
|
|
releaseLib,
|
|
/npx --yes "npm@\$BUNDLED_NPM_PUBLISH_VERSION" "\$@" --ignore-scripts/,
|
|
);
|
|
assert.match(releaseLib, /"\$@" --ignore-scripts --loglevel verbose/);
|
|
assert.match(releaseLib, /run_bundled_npm_publish publish --tag "\$dist_tag"/);
|
|
assert.doesNotMatch(releaseLib, /run_bundled_npm_publish publish "\.\/\$tarball"/);
|
|
});
|
|
|
|
test("npm builds use corepack instead of requiring a global pnpm", () => {
|
|
assert.match(buildNpmScript, /corepack pnpm -r typecheck/);
|
|
assert.doesNotMatch(buildNpmScript, /^\s*pnpm -r typecheck/m);
|
|
});
|
|
|
|
|
|
test("installed ACPX runtime persists and restores optional goal capabilities", () => {
|
|
const requireRunner = createRequire(new URL("../packages/paperclip-runner/package.json", import.meta.url));
|
|
const runtimeSource = readFileSync(requireRunner.resolve("acpx/runtime"), "utf8");
|
|
const start = runtimeSource.indexOf("function persistedGoalCapability(");
|
|
const end = runtimeSource.indexOf("function planUpdateEvent(", start);
|
|
assert.ok(start >= 0 && end > start, "the installed patch must define both goal helpers");
|
|
const helpers = runInNewContext(runtimeSource.slice(start, end) + ";({ persistedGoalCapability, restoredGoalCapability })", {
|
|
isRecord: (value) => value !== null && typeof value === "object" && !Array.isArray(value),
|
|
});
|
|
assert.equal(helpers.persistedGoalCapability(undefined), undefined);
|
|
assert.equal(helpers.restoredGoalCapability(undefined), undefined);
|
|
const goal = { version: 1, controlMethod: "_session/goal", actions: ["set", "pause", "clear"] };
|
|
const saved = JSON.parse(JSON.stringify(helpers.persistedGoalCapability(goal)));
|
|
assert.equal(saved.control_method, "_session/goal");
|
|
assert.deepEqual(JSON.parse(JSON.stringify(helpers.restoredGoalCapability(saved))), goal);
|
|
assert.equal(helpers.persistedGoalCapability({ ...goal, version: 2 }), undefined);
|
|
assert.equal(helpers.persistedGoalCapability({ ...goal, actions: ["set"] }), undefined);
|
|
});
|