History

Apps console: source management UI and e2e coverage (#1365)

* Add console support for custom app tools * Only claim unambiguous git URLs in connect-dialog detection * Add custom tools console recording * Omit the required key when schema projection empties it * Assert projection output stays JSON-encodable without JSON.parse * Derive app tool schemas via Standard Schema * Cover zod schemas in custom tools e2e * Pass the required target option to Standard Schema converters * Make app sources top-level integrations * Limit local directory app sources to local host * Add local directory picker endpoint * Update local app dependency lockfile * Cover local directory picker in local e2e * Fix CLI node ambient types * Cover custom tools on cloud target * Complete local custom tools picker e2e * Require auth in custom tools git e2e * Exercise custom tools run tab * Cover custom tools collect failure recovery * Handle cloud auth status in custom tools e2e * Show local app source shape in directory picker * Tighten directory picker e2e tag assertion * Remove implicit GitHub connection token from git sources * Package workerd for local binaries * Trust bundled root certificates in workerd outbound TLS workerd's built-in internet service cannot read the Windows system trust store, so npm installs inside the publish worker failed TLS verification. Declare the internet service explicitly with the runtime's bundled Mozilla roots, surface workerd stderr in startup failures, and fail the bundle when a dependency install warning is present instead of publishing a broken bundle. * Add packed-binary custom tools e2e scenarios The new CLI VM scenario proves the compiled executor binary can publish and invoke local-directory custom tools inside macOS, Linux, and Windows guests. The desktop VM scenario proves the packaged sidecar daemon does the same on desktop bundles, and these manual-run VM projects remain outside the default CI chain by design.

Rhys Sullivan committed 4 months ago

Commit 28fdd311 parent f59577164 files · +4946 −290Browse files

Showing the changes to apps/cli/scripts (1 of 64 files). Show all

Unified · Split

apps/cli/scripts/smoke-packed-apps.ts+192−0
@@ -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 */
2import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { join, resolve } from "node:path";
5import { spawn, type Subprocess } from "bun";
6
7const binary = process.argv[2];
8if (!binary) throw new Error("usage: bun apps/cli/scripts/smoke-packed-apps.ts <packed-binary>");
9
10const AUTH_TOKEN = "packed-apps-smoke-token";
11const READY_TIMEOUT_MS = 45_000;
12
13const fail = (message: string): never => {
14 throw new Error(`[smoke-packed-apps] FAIL: ${message}`);
15};
16
17const 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
31const 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
76const 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
192await main();