Files
openclaw/extensions/acpx/src/ensure.test.ts
Bob ea15819ecf ACP: harden startup and move configured routing behind plugin seams (#48197)
* ACPX: keep plugin-local runtime installs out of dist

* Gateway: harden ACP startup and service PATH

* ACP: reinitialize error-state configured bindings

* ACP: classify pre-turn runtime failures as session init failures

* Plugins: move configured ACP routing behind channel seams

* Telegram tests: align startup probe assertions after rebase

* Discord: harden ACP configured binding recovery

* ACP: recover Discord bindings after stale runtime exits

* ACPX: replace dead sessions during ensure

* Discord: harden ACP binding recovery

* Discord: fix review follow-ups

* ACP bindings: load channel snapshots across workspaces

* ACP bindings: cache snapshot channel plugin resolution

* Experiments: add ACP pluginification holy grail plan

* Experiments: rename ACP pluginification plan doc

* Experiments: drop old ACP pluginification doc path

* ACP: move configured bindings behind plugin services

* Experiments: update bindings capability architecture plan

* Bindings: isolate configured binding routing and targets

* Discord tests: fix runtime env helper path

* Tests: fix channel binding CI regressions

* Tests: normalize ACP workspace assertion on Windows

* Bindings: isolate configured binding registry

* Bindings: finish configured binding cleanup

* Bindings: finish generic cleanup

* Bindings: align runtime approval callbacks

* ACP: delete residual bindings barrel

* Bindings: restore legacy compatibility

* Revert "Bindings: restore legacy compatibility"

This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe.

* Tests: drop ACP route legacy helper names

* Discord/ACP: fix binding regressions

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
2026-03-17 17:27:52 +01:00

319 lines
8.5 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ACPX_LOCAL_INSTALL_COMMAND,
ACPX_PINNED_VERSION,
buildAcpxLocalInstallCommand,
} from "./config.js";
const { resolveSpawnFailureMock, spawnAndCollectMock } = vi.hoisted(() => ({
resolveSpawnFailureMock: vi.fn<
(error: unknown, cwd: string) => "missing-command" | "missing-cwd" | null
>(() => null),
spawnAndCollectMock: vi.fn(),
}));
vi.mock("./runtime-internals/process.js", () => ({
resolveSpawnFailure: resolveSpawnFailureMock,
spawnAndCollect: spawnAndCollectMock,
}));
import { checkAcpxVersion, ensureAcpx } from "./ensure.js";
describe("acpx ensure", () => {
const tempDirs: string[] = [];
beforeEach(() => {
resolveSpawnFailureMock.mockReset();
resolveSpawnFailureMock.mockReturnValue(null);
spawnAndCollectMock.mockReset();
});
function makeTempAcpxInstall(version: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-ensure-test-"));
tempDirs.push(root);
const packageRoot = path.join(root, "node_modules", "acpx");
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
fs.mkdirSync(path.join(root, "node_modules", ".bin"), { recursive: true });
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "acpx", version }, null, 2),
"utf8",
);
fs.writeFileSync(path.join(packageRoot, "dist", "cli.js"), "#!/usr/bin/env node\n", "utf8");
const binPath = path.join(root, "node_modules", ".bin", "acpx");
fs.symlinkSync(path.join(packageRoot, "dist", "cli.js"), binPath);
return binPath;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function mockEnsureInstallFlow() {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "added 1 package\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
}
function expectEnsureInstallCalls(stripProviderAuthEnvVars?: boolean) {
expect(spawnAndCollectMock.mock.calls[0]?.[0]).toMatchObject({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars,
});
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
command: "npm",
args: [
"install",
"--omit=dev",
"--no-save",
"--package-lock=false",
`acpx@${ACPX_PINNED_VERSION}`,
],
cwd: "/plugin",
stripProviderAuthEnvVars,
});
expect(spawnAndCollectMock.mock.calls[2]?.[0]).toMatchObject({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars,
});
}
it("accepts the pinned acpx version", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
const result = await checkAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(result).toEqual({
ok: true,
version: ACPX_PINNED_VERSION,
expectedVersion: ACPX_PINNED_VERSION,
});
expect(spawnAndCollectMock).toHaveBeenCalledWith({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars: undefined,
});
});
it("reports version mismatch", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
});
const result = await checkAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(result).toMatchObject({
ok: false,
reason: "version-mismatch",
expectedVersion: ACPX_PINNED_VERSION,
installedVersion: "0.0.9",
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
});
});
it("falls back to package.json version when --version is unsupported", async () => {
const command = makeTempAcpxInstall(ACPX_PINNED_VERSION);
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "",
stderr: "error: unknown option '--version'",
code: 2,
error: null,
});
const result = await checkAcpxVersion({
command,
cwd: path.dirname(path.dirname(command)),
expectedVersion: ACPX_PINNED_VERSION,
});
expect(result).toEqual({
ok: true,
version: ACPX_PINNED_VERSION,
expectedVersion: ACPX_PINNED_VERSION,
});
});
it("accepts command availability when expectedVersion is unset", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "Usage: acpx [options]\n",
stderr: "",
code: 0,
error: null,
});
const result = await checkAcpxVersion({
command: "/custom/acpx",
cwd: "/custom",
expectedVersion: undefined,
});
expect(result).toEqual({
ok: true,
version: "unknown",
expectedVersion: undefined,
});
expect(spawnAndCollectMock).toHaveBeenCalledWith({
command: "/custom/acpx",
args: ["--help"],
cwd: "/custom",
stripProviderAuthEnvVars: undefined,
});
});
it("forwards stripProviderAuthEnvVars to version checks", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "Usage: acpx [options]\n",
stderr: "",
code: 0,
error: null,
});
await checkAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: undefined,
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock).toHaveBeenCalledWith({
command: "/plugin/node_modules/.bin/acpx",
args: ["--help"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
});
it("installs and verifies pinned acpx when precheck fails", async () => {
mockEnsureInstallFlow();
await ensureAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(spawnAndCollectMock).toHaveBeenCalledTimes(3);
expectEnsureInstallCalls();
});
it("threads stripProviderAuthEnvVars through version probes and install", async () => {
mockEnsureInstallFlow();
await ensureAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
stripProviderAuthEnvVars: true,
});
expectEnsureInstallCalls(true);
});
it("fails with actionable error when npm install fails", async () => {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "",
stderr: "network down",
code: 1,
error: null,
});
await expect(
ensureAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
}),
).rejects.toThrow("failed to install plugin-local acpx");
});
it("skips install path when allowInstall=false", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "",
stderr: "",
code: 0,
error: new Error("not found"),
});
resolveSpawnFailureMock.mockReturnValue("missing-command");
await expect(
ensureAcpx({
command: "/custom/acpx",
pluginRoot: "/plugin",
expectedVersion: undefined,
allowInstall: false,
}),
).rejects.toThrow("acpx command not found at /custom/acpx");
expect(spawnAndCollectMock).toHaveBeenCalledTimes(1);
});
it("uses expectedVersion for install command metadata", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
});
const result = await checkAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: "0.2.0",
});
expect(result).toMatchObject({
ok: false,
installCommand: buildAcpxLocalInstallCommand("0.2.0"),
});
});
});