157 lines
5.5 KiB
Go
157 lines
5.5 KiB
Go
// End-to-end load test for the embedded pi-extension host (#266).
|
|
//
|
|
// This exercises the REAL host (pihost.mjs) against the REAL pi SDK
|
|
// (@earendil-works/pi-coding-agent) via internal/plugin.Load, driving the
|
|
// actual newline-delimited JSON-RPC 2.0 handshake over a subprocess. A tiny
|
|
// fixture pi extension is written to a temp dir; the test loads it through the
|
|
// host, asserts the fixture's registered command shows up in the manifest, and
|
|
// asserts commands/call returns the prompt the fixture emits via
|
|
// pi.sendUserMessage (which the host captures into CommandCallResult.Prompt).
|
|
//
|
|
// It is guarded: it skips cleanly when `node` is not on PATH or when the pi SDK
|
|
// cannot be resolved, so CI without a Node/SDK toolchain still passes.
|
|
package pihost_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/smallnest/pigo/internal/pihost"
|
|
"github.com/smallnest/pigo/internal/plugin"
|
|
)
|
|
|
|
// piSDKProbe mirrors pihost.mjs's own SDK resolution (loadSdk): try a direct
|
|
// import of the package, then fall back to importing the package entry under
|
|
// the global npm root. Reporting availability the same way the host resolves
|
|
// it keeps the gate honest — the test skips only when the host itself could
|
|
// not load the SDK.
|
|
const piSDKProbe = `
|
|
const PKG = "@earendil-works/pi-coding-agent";
|
|
import("node:child_process").then(async ({ execFileSync }) => {
|
|
try { await import(PKG); process.exit(0); } catch {}
|
|
try {
|
|
const root = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim();
|
|
const { pathToFileURL } = await import("node:url");
|
|
const path = await import("node:path");
|
|
for (const entry of [
|
|
path.join(root, PKG, "dist", "index.js"),
|
|
path.join(root, PKG, "index.js"),
|
|
]) {
|
|
try { await import(pathToFileURL(entry).href); process.exit(0); } catch {}
|
|
}
|
|
} catch {}
|
|
process.exit(1);
|
|
}).catch(() => process.exit(1));
|
|
`
|
|
|
|
// piHostAvailable reports whether the E2E prerequisites are satisfied, and a
|
|
// human-readable reason to log when they are not. It requires `node` on PATH
|
|
// and a resolvable pi SDK; the SDK probe is bounded so a hung npm cannot stall
|
|
// the suite.
|
|
func piHostAvailable() (ok bool, reason string) {
|
|
if _, err := exec.LookPath("node"); err != nil {
|
|
return false, "node is not on PATH"
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "node", "--input-type=module", "-e", piSDKProbe)
|
|
if err := cmd.Run(); err != nil {
|
|
return false, "pi SDK (@earendil-works/pi-coding-agent) is not resolvable: " + err.Error()
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
// writeFixtureExtension writes a minimal pi extension package into dir: a
|
|
// package.json declaring index.js as its sole pi extension, and an index.js
|
|
// that registers a single "e2e" command whose handler emits a known string via
|
|
// pi.sendUserMessage. The host captures that message into the command result's
|
|
// Prompt.
|
|
func writeFixtureExtension(t *testing.T, dir string) {
|
|
t.Helper()
|
|
|
|
pkgJSON := `{
|
|
"name": "pi-e2e-fixture",
|
|
"version": "0.0.0",
|
|
"type": "module",
|
|
"pi": { "extensions": ["index.js"] }
|
|
}
|
|
`
|
|
indexJS := `export default (pi) => {
|
|
pi.registerCommand("e2e", {
|
|
description: "e2e probe",
|
|
handler: async (args, ctx) => {
|
|
pi.sendUserMessage("E2E_PROMPT_OK");
|
|
},
|
|
});
|
|
};
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkgJSON), 0o644); err != nil {
|
|
t.Fatalf("write package.json: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "index.js"), []byte(indexJS), 0o644); err != nil {
|
|
t.Fatalf("write index.js: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPiHostExtensionE2E(t *testing.T) {
|
|
if ok, reason := piHostAvailable(); !ok {
|
|
t.Skip("skipping pi-host E2E: " + reason)
|
|
}
|
|
|
|
// Exercise the EMBEDDED host bytes: write pihost.Script to a temp .mjs so
|
|
// the test drives exactly what pigo ships, not a stray on-disk copy.
|
|
tmp := t.TempDir()
|
|
hostPath := filepath.Join(tmp, "pihost.mjs")
|
|
if err := os.WriteFile(hostPath, pihost.Script, 0o644); err != nil {
|
|
t.Fatalf("write embedded pihost.mjs: %v", err)
|
|
}
|
|
|
|
// The fixture extension lives in its own package dir so the host's pkgDir
|
|
// filter (which keeps only extensions under the target dir) selects it.
|
|
pkgDir := filepath.Join(tmp, "fixture")
|
|
if err := os.MkdirAll(pkgDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir fixture: %v", err)
|
|
}
|
|
writeFixtureExtension(t, pkgDir)
|
|
|
|
// Load the extension through the real host. Args mirror the host's argv
|
|
// contract: `node pihost.mjs <pkgDir>`. plugin.Load performs the initialize
|
|
// handshake and decodes the manifest.
|
|
p, err := plugin.Load("node", []string{hostPath, pkgDir}, os.Stderr)
|
|
if err != nil {
|
|
t.Fatalf("plugin.Load(pihost): %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = p.Close() })
|
|
|
|
// initialize must surface the fixture's registered command.
|
|
if got := p.Manifest.Name; got != "pi-e2e-fixture" {
|
|
t.Errorf("manifest name = %q, want %q", got, "pi-e2e-fixture")
|
|
}
|
|
var found bool
|
|
for _, c := range p.Manifest.Commands {
|
|
if c.Name == "e2e" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("manifest commands %+v missing %q", p.Manifest.Commands, "e2e")
|
|
}
|
|
|
|
// commands/call must run the handler and return the captured prompt.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
res, err := p.CallCommand(ctx, "e2e", json.RawMessage(`""`))
|
|
if err != nil {
|
|
t.Fatalf("CallCommand(e2e): %v", err)
|
|
}
|
|
if res.Prompt != "E2E_PROMPT_OK" {
|
|
t.Fatalf("command prompt = %q (notifications %+v), want %q", res.Prompt, res.Notifications, "E2E_PROMPT_OK")
|
|
}
|
|
}
|