import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ROOT } from "../build/build.ts"; import { deliverGit, snapshot } from "./index.ts"; // One eligible extension whose built package registers a runnable tool, plus a // release scenario that loads the package and executes that tool. This proves // the Git delivery channel end to end without a real product selection. const TOOL_EXTENSION = `export default function (pi) { pi.registerTool({ name: "smoke_echo", label: "Smoke Echo", description: "Echoes the provided text.", parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"], }, async execute(_id, args) { return { content: [{ type: "text", text: String(args.text) }], details: {} }; }, }); } `; const RUN_TOOL_SCENARIO = `export async function verify({ session }) { const [extension] = session.extensionRunner.extensions; if (!extension) throw new Error("no extension loaded from delivered package"); const tool = extension.tools.get("smoke_echo"); if (!tool) throw new Error("smoke_echo tool was not registered"); const result = await tool.definition.execute("smoke-call", { text: "hi" }); const text = result?.content?.[0]?.text; if (text !== "hi") throw new Error("unexpected tool output: " + text); } `; const trash: string[] = []; function eligibleRepo(): { root: string; dist: string; release: string } { const root = mkdtempSync(join(tmpdir(), "pi-ext-smoke-src-")); trash.push(root); const ext = join(root, "extensions", "demo"); const dist = join(root, "dist"); mkdirSync(ext, { recursive: true }); writeFileSync(join(root, "package.json"), '{"version":"9.9.9"}\n'); writeFileSync(join(ext, "scenario.mjs"), RUN_TOOL_SCENARIO); writeFileSync( join(ext, "package.json"), JSON.stringify({ "pi-ext": { publish: true, publicName: "@owned/demo", git: "file:///never-push", releaseScenario: "scenario.mjs", }, }), ); mkdirSync(join(dist, "demo"), { recursive: true }); writeFileSync(join(dist, "demo", "index.js"), TOOL_EXTENSION); writeFileSync( join(dist, "demo", ".pi-ext-build.json"), '{"files":["index.js","package.json"]}\n', ); writeFileSync( join(dist, "demo", "package.json"), '{"name":"@bugabinga/pi-ext-demo","private":true}\n', ); return { root, dist, release: join(root, "release") }; } afterEach(() => { for (const path of trash.splice(0)) rmSync(path, { recursive: true, force: true }); }); describe("git consumer smoke", () => { it("installs from the Git delivery and runs a tool without repository source", () => { const input = eligibleRepo(); snapshot("demo", { root: input.root, distRoot: input.dist, releaseRoot: input.release, }); const delivery = deliverGit("demo", { distRoot: input.dist, releaseRoot: input.release, }); trash.push(delivery); // Model a consumer install: clone the delivery into a fresh directory // that contains no repository source or toolchain. const consumer = mkdtempSync(join(tmpdir(), "pi-ext-smoke-consumer-")); trash.push(consumer); const clone = spawnSync("git", ["clone", "-q", delivery, consumer], { encoding: "utf8", }); expect(clone.status, clone.stderr).toBe(0); // The consumer carries only delivered bytes, never repository source. expect(existsSync(join(consumer, "extensions"))).toBe(false); expect(existsSync(join(consumer, "index.js"))).toBe(true); // Model an independently installed Pi available to the consumer without // vendoring it into the delivered package (ESM ignores NODE_PATH). symlinkSync( join(ROOT, "node_modules"), join(consumer, "node_modules"), process.platform === "win32" ? "junction" : "dir", ); const result = spawnSync( process.execPath, ["--no-warnings", "verify-package.mjs", "scenario.mjs"], { cwd: consumer, encoding: "utf8", env: { ...process.env, PI_OFFLINE: "1", PI_CODING_AGENT_DIR: join(consumer, ".agent"), }, timeout: 120_000, }, ); expect(result.stdout + result.stderr).toContain( "PI_EXT_STANDALONE_VERIFY=ok", ); expect(result.status, result.stderr).toBe(0); }, 150_000); });