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 */
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();