Showing the changes to apps/cli/scripts (1 of 8 files). Show all
Unified · Split
| @@ -0,0 +1,192 @@ | ||
| 1 | +/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-promise-reject, executor/no-promise-catch -- boundary: standalone build smoke harness tears down a spawned packed binary */ | |
| 2 | +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 3 | +import { tmpdir } from "node:os"; | |
| 4 | +import { join, resolve } from "node:path"; | |
| 5 | +import { spawn, type Subprocess } from "bun"; | |
| 6 | + | |
| 7 | +const binary = process.argv[2]; | |
| 8 | +if (!binary) throw new Error("usage: bun apps/cli/scripts/smoke-packed-apps.ts <packed-binary>"); | |
| 9 | + | |
| 10 | +const AUTH_TOKEN = "packed-apps-smoke-token"; | |
| 11 | +const READY_TIMEOUT_MS = 45_000; | |
| 12 | + | |
| 13 | +const fail = (message: string): never => { | |
| 14 | + throw new Error(`[smoke-packed-apps] FAIL: ${message}`); | |
| 15 | +}; | |
| 16 | + | |
| 17 | +const requestJson = async <T>(origin: string, path: string, init: RequestInit = {}): Promise<T> => { | |
| 18 | + const response = await fetch(new URL(path, origin), { | |
| 19 | + ...init, | |
| 20 | + headers: { | |
| 21 | + authorization: `Bearer ${AUTH_TOKEN}`, | |
| 22 | + ...(init.body === undefined ? {} : { "content-type": "application/json" }), | |
| 23 | + ...init.headers, | |
| 24 | + }, | |
| 25 | + }); | |
| 26 | + const text = await response.text(); | |
| 27 | + if (!response.ok) fail(`${init.method ?? "GET"} ${path} returned ${response.status}: ${text}`); | |
| 28 | + return (text.length > 0 ? JSON.parse(text) : null) as T; | |
| 29 | +}; | |
| 30 | + | |
| 31 | +const waitForReadyPort = async (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<number> => | |
| 32 | + new Promise((resolveReady, rejectReady) => { | |
| 33 | + const timer = setTimeout( | |
| 34 | + () => rejectReady(new Error("packed binary did not report ready in time")), | |
| 35 | + READY_TIMEOUT_MS, | |
| 36 | + ); | |
| 37 | + let stdout = ""; | |
| 38 | + let stderr = ""; | |
| 39 | + const finish = (result: number | Error): void => { | |
| 40 | + clearTimeout(timer); | |
| 41 | + if (result instanceof Error) rejectReady(result); | |
| 42 | + else resolveReady(result); | |
| 43 | + }; | |
| 44 | + void proc.exited.then((code) => { | |
| 45 | + finish(new Error(`packed binary exited before ready with code ${code}: ${stderr}`)); | |
| 46 | + }); | |
| 47 | + void (async () => { | |
| 48 | + const reader = proc.stdout.getReader(); | |
| 49 | + const decoder = new TextDecoder(); | |
| 50 | + try { | |
| 51 | + while (true) { | |
| 52 | + const chunk = await reader.read(); | |
| 53 | + if (chunk.done) return; | |
| 54 | + stdout += decoder.decode(chunk.value); | |
| 55 | + const match = stdout.match(/EXECUTOR_READY:(\d+)/); | |
| 56 | + if (match) { | |
| 57 | + finish(Number(match[1])); | |
| 58 | + return; | |
| 59 | + } | |
| 60 | + } | |
| 61 | + } catch (cause) { | |
| 62 | + finish(cause instanceof Error ? cause : new Error(String(cause))); | |
| 63 | + } | |
| 64 | + })(); | |
| 65 | + void (async () => { | |
| 66 | + const reader = proc.stderr.getReader(); | |
| 67 | + const decoder = new TextDecoder(); | |
| 68 | + while (true) { | |
| 69 | + const chunk = await reader.read(); | |
| 70 | + if (chunk.done) return; | |
| 71 | + stderr += decoder.decode(chunk.value); | |
| 72 | + } | |
| 73 | + })().catch(() => undefined); | |
| 74 | + }); | |
| 75 | + | |
| 76 | +const main = async (): Promise<void> => { | |
| 77 | + const fixture = await mkdtemp(join(tmpdir(), "executor-packed-apps-fixture-")); | |
| 78 | + const dataDir = await mkdtemp(join(tmpdir(), "executor-packed-apps-data-")); | |
| 79 | + const scopeDir = await mkdtemp(join(tmpdir(), "executor-packed-apps-scope-")); | |
| 80 | + let proc: Subprocess<"ignore", "pipe", "pipe"> | null = null; | |
| 81 | + try { | |
| 82 | + await mkdir(join(fixture, "tools"), { recursive: true }); | |
| 83 | + await writeFile( | |
| 84 | + join(fixture, "tools", "greet.ts"), | |
| 85 | + ` | |
| 86 | + import { z } from "zod"; | |
| 87 | + import { defineTool } from "executor:app"; | |
| 88 | + | |
| 89 | + const Input = z.object({ name: z.string() }); | |
| 90 | + | |
| 91 | + export default defineTool({ | |
| 92 | + description: "Greet", | |
| 93 | + input: Input, | |
| 94 | + async handler(input) { | |
| 95 | + return { greeting: "hello " + input.name }; | |
| 96 | + }, | |
| 97 | + }); | |
| 98 | + `, | |
| 99 | + ); | |
| 100 | + await writeFile( | |
| 101 | + join(fixture, "package.json"), | |
| 102 | + `${JSON.stringify({ name: "packed-apps-smoke", dependencies: { zod: "4.3.6" } })}\n`, | |
| 103 | + ); | |
| 104 | + await writeFile(join(fixture, "bun.lock"), ""); | |
| 105 | + | |
| 106 | + proc = spawn({ | |
| 107 | + cmd: [ | |
| 108 | + resolve(binary), | |
| 109 | + "daemon", | |
| 110 | + "run", | |
| 111 | + "--foreground", | |
| 112 | + "--port", | |
| 113 | + "0", | |
| 114 | + "--hostname", | |
| 115 | + "127.0.0.1", | |
| 116 | + "--auth-token", | |
| 117 | + AUTH_TOKEN, | |
| 118 | + ], | |
| 119 | + env: { | |
| 120 | + ...process.env, | |
| 121 | + EXECUTOR_CLIENT: "desktop", | |
| 122 | + EXECUTOR_DATA_DIR: dataDir, | |
| 123 | + EXECUTOR_SCOPE_DIR: scopeDir, | |
| 124 | + }, | |
| 125 | + stdin: "ignore", | |
| 126 | + stdout: "pipe", | |
| 127 | + stderr: "pipe", | |
| 128 | + }); | |
| 129 | + | |
| 130 | + const port = await waitForReadyPort(proc); | |
| 131 | + const origin = `http://127.0.0.1:${port}`; | |
| 132 | + console.log(`[smoke-packed-apps] ready on ${origin}`); | |
| 133 | + | |
| 134 | + await requestJson(origin, "/api/apps/sources", { | |
| 135 | + method: "POST", | |
| 136 | + body: JSON.stringify({ | |
| 137 | + kind: "local-directory", | |
| 138 | + slug: "packed-apps-smoke", | |
| 139 | + app: "packed-apps-smoke", | |
| 140 | + path: fixture, | |
| 141 | + }), | |
| 142 | + }); | |
| 143 | + | |
| 144 | + const sync = await requestJson<{ | |
| 145 | + readonly status: string; | |
| 146 | + readonly tools: readonly string[]; | |
| 147 | + readonly errors?: readonly unknown[]; | |
| 148 | + }>(origin, "/api/apps/sources/packed-apps-smoke/sync", { method: "POST" }); | |
| 149 | + if (sync.status !== "published") fail(`sync failed: ${JSON.stringify(sync.errors)}`); | |
| 150 | + if (JSON.stringify(sync.tools) !== JSON.stringify(["greet"])) { | |
| 151 | + fail(`expected greet tool, got ${JSON.stringify(sync.tools)}`); | |
| 152 | + } | |
| 153 | + | |
| 154 | + const invoked = await requestJson<{ | |
| 155 | + readonly status: string; | |
| 156 | + readonly structured: unknown; | |
| 157 | + readonly isError: boolean; | |
| 158 | + readonly text: string; | |
| 159 | + }>(origin, "/api/executions", { | |
| 160 | + method: "POST", | |
| 161 | + body: JSON.stringify({ | |
| 162 | + code: `export default await tools["packed-apps-smoke.org.published.greet"]({ name: "Ada" })`, | |
| 163 | + autoApprove: true, | |
| 164 | + }), | |
| 165 | + }); | |
| 166 | + if (invoked.status !== "completed" || invoked.isError) { | |
| 167 | + fail(`execution failed: ${JSON.stringify(invoked)}`); | |
| 168 | + } | |
| 169 | + if ( | |
| 170 | + JSON.stringify(invoked.structured) !== | |
| 171 | + JSON.stringify({ | |
| 172 | + status: "completed", | |
| 173 | + result: { ok: true, data: { greeting: "hello Ada" } }, | |
| 174 | + logs: [], | |
| 175 | + }) | |
| 176 | + ) { | |
| 177 | + fail(`unexpected execution result: ${JSON.stringify(invoked)}`); | |
| 178 | + } | |
| 179 | + console.log("[smoke-packed-apps] OK - local-directory app source bundled and invoked"); | |
| 180 | + } finally { | |
| 181 | + if (proc) { | |
| 182 | + proc.kill("SIGTERM"); | |
| 183 | + await Promise.race([proc.exited, new Promise((resolve) => setTimeout(resolve, 3000))]); | |
| 184 | + proc.kill("SIGKILL"); | |
| 185 | + } | |
| 186 | + await rm(fixture, { recursive: true, force: true }).catch(() => undefined); | |
| 187 | + await rm(dataDir, { recursive: true, force: true }).catch(() => undefined); | |
| 188 | + await rm(scopeDir, { recursive: true, force: true }).catch(() => undefined); | |
| 189 | + } | |
| 190 | +}; | |
| 191 | + | |
| 192 | +await main(); | |