/** * Test client for running a plugin process and sending commands via gRPC. * Hides all gRPC/proto internals — plugin developers just send payloads and get results. */ import * as grpc from "@grpc/grpc-js"; import * as protoLoader from "@grpc/proto-loader"; import { resolve } from "path"; export interface PluginTestEvent { started?: {}; output?: { stdout: string; stderr: string; data: Record | null; }; result?: { success: boolean; data: Buffer; error: string; error_code: string; exit_code: number; }; } export interface ExecuteResponse { events: PluginTestEvent[]; result: { success: boolean; data: unknown; error: string; errorCode: string; exitCode: number; } | null; } export type EventCallback = (event: PluginTestEvent) => void; export interface PluginTestClient { execute( payload: unknown, opts?: { commandType?: string; actionType?: string } ): Promise; executeStreaming( payload: unknown, onEvent: EventCallback, opts?: { commandType?: string; actionType?: string } ): Promise; stop(): void; } export async function startPluginProcess( entrypoint: string, opts?: { cwd?: string; env?: Record } ): Promise { const cwd = opts?.cwd ?? resolve(entrypoint, ".."); const pluginProcess = Bun.spawn(["bun", "run", entrypoint], { cwd, env: { ...process.env, ...opts?.env, NP_AGENT_PLUGIN: "np-agent-v1", // This helper only ever runs a plugin for local dev/test (the real agent // execs the binary directly). Force in-memory platform mode so a dev/test // run can never hit the real API, even if NP_API_KEY is in the env. NP_MODE: process.env.NP_MODE || "dev", }, stdout: "pipe", stderr: "pipe", }); // Read handshake line: 1|1|unix||grpc| const reader = pluginProcess.stdout.getReader(); const { value } = await reader.read(); reader.releaseLock(); const handshake = new TextDecoder().decode(value).trim(); const socketPath = handshake.split("|")[3]; // Stream stdout/stderr from the plugin process in real-time. // liveCallback is set during executeStreaming() for immediate delivery. let liveCallback: EventCallback | null = null; async function pipeStream( stream: ReadableStream, isStderr: boolean ) { const r = stream.getReader(); const decoder = new TextDecoder(); try { while (true) { const { done, value: chunk } = await r.read(); if (done) break; const text = decoder.decode(chunk); // Tee: print to terminal AND stream to UI if (isStderr) { process.stderr.write(chunk); } else { process.stdout.write(chunk); } if (text.trim() && liveCallback) { liveCallback({ output: { stdout: isStderr ? "" : text, stderr: isStderr ? text : "", data: null, }, }); } } } catch {} } pipeStream(pluginProcess.stdout, false); pipeStream(pluginProcess.stderr, true); // Load proto and create client const protoPath = resolve(import.meta.dir, "../internal/plugin.proto"); const packageDefinition = protoLoader.loadSync(protoPath, { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true, }); const proto = grpc.loadPackageDefinition(packageDefinition) as any; const client = new proto.npagent.plugin.v1.ExecutorPlugin( `unix://${socketPath}`, grpc.credentials.createInsecure() ); // Wait for connection await new Promise((resolve, reject) => { client.waitForReady(Date.now() + 5000, (err: Error | null) => { if (err) reject(err); else resolve(); }); }); return { execute(payload, execOpts) { return new Promise((resolve, reject) => { const req = { message_id: `test-${Date.now()}`, command_type: execOpts?.commandType ?? "scope", payload: Buffer.from(JSON.stringify(payload)), supports_partial: true, can_cancel: false, reply_to: "", action_type: execOpts?.actionType ?? "", }; const call = client.Execute(req); const events: PluginTestEvent[] = []; let result: ExecuteResponse["result"] = null; call.on("data", (event: any) => { events.push(event); if (event.result) { let data: unknown = null; try { const buf = event.result.data; if (buf && buf.length > 0) { data = JSON.parse(Buffer.from(buf).toString()); } } catch {} result = { success: event.result.success, data, error: event.result.error, errorCode: event.result.error_code, exitCode: event.result.exit_code, }; } }); call.on("end", () => { resolve({ events, result }); }); call.on("error", reject); }); }, executeStreaming(payload, onEvent, execOpts) { liveCallback = onEvent; return new Promise((resolve, reject) => { const req = { message_id: `test-${Date.now()}`, command_type: execOpts?.commandType ?? "scope", payload: Buffer.from(JSON.stringify(payload)), supports_partial: true, can_cancel: false, reply_to: "", action_type: execOpts?.actionType ?? "", }; const call = client.Execute(req); const events: PluginTestEvent[] = []; let result: ExecuteResponse["result"] = null; call.on("data", (event: any) => { events.push(event); onEvent(event); if (event.result) { let data: unknown = null; try { const buf = event.result.data; if (buf && buf.length > 0) { data = JSON.parse(Buffer.from(buf).toString()); } } catch {} result = { success: event.result.success, data, error: event.result.error, errorCode: event.result.error_code, exitCode: event.result.exit_code, }; } }); call.on("end", () => { liveCallback = null; resolve({ events, result }); }); call.on("error", reject); }); }, stop() { client.close(); pluginProcess.kill(); }, }; }