Public: anyone can read this; the crew can change it.

test/apps-api.test.ts runs in under 8 s #11060

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/11054-test-apps-api-test-ts-runs-in-under-8-s → main 1 commit fork

  • Merged. torque/11054-test-apps-api-test-ts-runs-in-under-8-s was merged into main as 475335c.

Review

Sign in to review this change.
1 file changed +188 −113
test/apps-api.test.ts +188 −113
@@ -77,20 +77,95 @@ describe("apps, runs and the crew over the API", () => {
7777 ...(token === undefined ? {} : { token }),
7878 });
7979
80 async function untilRun(where: (run: Record<string, unknown>) => boolean) {
81 const deadline = Date.now() + 60_000;
82 while (Date.now() < deadline) {
83 const runs = (await (
84 await api("/api/v1/repos/alice/hello/runs")
85 ).json()) as { items: Record<string, unknown>[] };
86 const run = runs.items.find(where);
87 if (run && ["ok", "failed", "cancelled"].includes(String(run.state)))
88 return run;
89 await Bun.sleep(100);
80 /**
81 * The first run of `alice/<repo>` that `where` picks, once it has settled.
82 * Woken by the bus's `run.finished`, never by a clock: the listener is
83 * on before each read, so a run that settles between the read and the
84 * wait still wakes it. The deadline only bounds a run that never settles.
85 */
86 async function untilRun(
87 repo: string,
88 where: (run: Record<string, unknown>) => boolean,
89 ) {
90 const deadline = Date.now() + 30_000;
91 for (;;) {
92 let wake = () => {};
93 const heard = new Promise<void>((r) => (wake = r));
94 const off = app.bus.subscribe((e) => {
95 if (e.kind === "run.finished") wake();
96 });
97 try {
98 const runs = (await (
99 await api(`/api/v1/repos/alice/${repo}/runs`)
100 ).json()) as { items: Record<string, unknown>[] };
101 const run = runs.items.find(where);
102 if (run && ["ok", "failed", "cancelled"].includes(String(run.state)))
103 return run;
104 const left = deadline - Date.now();
105 if (left <= 0) throw new Error(`run on ${repo} did not settle`);
106 let bound: ReturnType<typeof setTimeout> | undefined;
107 await Promise.race([
108 heard,
109 new Promise<void>((r) => (bound = setTimeout(r, left))),
110 ]);
111 clearTimeout(bound);
112 } finally {
113 off();
114 }
90115 }
91 throw new Error("run did not settle");
92116 }
93117
118 /** Make an app of alice's over the API, as rw; `extra` adds to the body. */
119 const makeApp = (name: string, extra: Record<string, unknown> = {}) =>
120 post("/api/v1/apps", { name, description: "Keep notes", ...extra }, rw);
121
122 /**
123 * The apps that one case each owns and changes, keyed by name: made side
124 * by side as the forge boots (`beforeAll` starts them), so their first
125 * deploys overlap instead of queueing one case behind another.
126 */
127 const OWN_APPS: Record<string, Record<string, unknown>> = {
128 opened: { visibility: "public" },
129 envy: { visibility: "public" },
130 hidden: { visibility: "public" },
131 crewed: { visibility: "public", agent: "bot" },
132 checked: {},
133 };
134 const owned = new Map<string, Promise<{ status: number; production: Record<string, unknown> }>>();
135 const startOwn = (name: string) => {
136 const made = makeApp(name, OWN_APPS[name]).then(async (res) => ({
137 status: res.status,
138 production: await untilRun(name, (r) => r.env === "production"),
139 }));
140 // Awaited by its case; a failure is that case's to report, not an
141 // unhandled rejection while another case runs.
142 made.catch(() => {});
143 owned.set(name, made);
144 };
145 /** An app of its own for a case that changes it, once production serves. */
146 async function servingApp(name: string) {
147 const made = await owned.get(name)!;
148 expect(made.status).toBe(201);
149 expect(made.production.state).toBe("ok");
150 return made.production;
151 }
152
153 /**
154 * `hello`: the public app with `bot` on its crew, made once by whichever
155 * case asks first. Cases that only read it share it; a case that changes
156 * an app makes its own, so the order the cases run in does not matter.
157 */
158 let helloMade:
159 | Promise<{ status: number; body: Record<string, unknown> }>
160 | undefined;
161 const hello = () =>
162 (helloMade ??= makeApp("hello", { visibility: "public", agent: "bot" }).then(
163 async (res) => ({
164 status: res.status,
165 body: (await res.json()) as Record<string, unknown>,
166 }),
167 ));
168
94169 beforeAll(async () => {
95170 dataDir = await mkdtemp(join(tmpdir(), "forge-apps-api-"));
96171 app = createApp({ dataDir, port: 0 });
@@ -111,6 +186,9 @@ describe("apps, runs and the crew over the API", () => {
111186 concurrency: 1,
112187 budgetUsd: 1,
113188 runTimeoutMs: 60_000,
189 // The pump on a short clock, so a queued run starts at once rather
190 // than on the next of the default five-second ticks.
191 tickMs: 50,
114192 });
115193 await seedAdmin(app, {
116194 username: "alice",
@@ -135,6 +213,12 @@ describe("apps, runs and the crew over the API", () => {
135213 ).token;
136214 admin = await mint("admin");
137215 rw = await mint("rw");
216 // The agent the cases put on crews and the plan picks.
217 expect(
218 (await post("/api/v1/agents", { username: "bot", budgetUsd: 1 })).status,
219 ).toBe(201);
220 for (const name of Object.keys(OWN_APPS)) startOwn(name);
221 void hello();
138222 }, 30_000);
139223
140224 afterAll(async () => {
@@ -146,10 +230,10 @@ describe("apps, runs and the crew over the API", () => {
146230 });
147231
148232 test("agents: admin creates, anyone signed in lists, rw cannot create", async () => {
149 const refused = await post("/api/v1/agents", { username: "bot" }, rw);
233 const refused = await post("/api/v1/agents", { username: "maker" }, rw);
150234 expect(refused.status).toBe(403);
151235 const made = await post("/api/v1/agents", {
152 username: "bot",
236 username: "maker",
153237 brief: "Be brief.",
154238 budgetUsd: 1,
155239 });
@@ -158,18 +242,18 @@ describe("apps, runs and the crew over the API", () => {
158242 username: string;
159243 budgetUsd: number;
160244 };
161 expect(agent.username).toBe("bot");
245 expect(agent.username).toBe("maker");
162246 expect(agent.budgetUsd).toBe(1);
163247 const listed = (await (
164248 await api("/api/v1/agents", { token: rw })
165249 ).json()) as { items: { username: string }[] };
166 expect(listed.items.map((a) => a.username)).toEqual(["bot"]);
250 expect(listed.items.map((a) => a.username)).toContain("maker");
167251 expect((await api("/api/v1/agents", { token: null })).status).toBe(401);
168252 // The agent is a user of kind agent, with no way to sign in.
169 expect(getUserByName(app.db, "bot")?.kind).toBe("agent");
253 expect(getUserByName(app.db, "maker")?.kind).toBe("agent");
170254 const login = await post(
171255 "/api/v1/tokens",
172 { username: "bot", password: "", name: "x", scope: "rw" },
256 { username: "maker", password: "", name: "x", scope: "rw" },
173257 null,
174258 );
175259 expect(login.status).toBe(401);
@@ -188,30 +272,28 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
188272 const before = process.env["FORGE_CLAUDE"];
189273 try {
190274 process.env["FORGE_CLAUDE"] = dir;
191 const picked = await post(
192 "/api/v1/apps",
193 { name: "picked", description: "Keep notes", pick: true },
194 rw,
275 // The four are made side by side; the planner is on hand for each, and
276 // only the one that asks for a pick with the boolean true gets one.
277 const [picked, ...unpicked] = await Promise.all(
278 (
279 [
280 ["picked", { pick: true }],
281 // No pick asked, or a pick that is not the boolean true: no plan.
282 ["nopick", {}],
283 ["strpick", { pick: "true" }],
284 ["withagent", { pick: true, agent: "bot" }],
285 ] as const
286 ).map(([name, extra]) => makeApp(name, extra)),
195287 );
196 expect(picked.status).toBe(201);
197 const body = (await picked.json()) as {
288 expect(picked!.status).toBe(201);
289 const body = (await picked!.json()) as {
198290 plan: { agent: string; summary: string; confidence: number } | null;
199291 issue: { assigneeName: string } | null;
200292 };
201293 expect(body.plan).toEqual({ agent: "bot", summary: "Build it", confidence: 0.7 });
202294 expect(body.issue?.assigneeName).toBe("bot");
203295
204 // No pick asked, or a pick that is not the boolean true: no plan.
205 for (const [name, extra] of [
206 ["nopick", {}],
207 ["strpick", { pick: "true" }],
208 ["withagent", { pick: true, agent: "bot" }],
209 ] as const) {
210 const res = await post(
211 "/api/v1/apps",
212 { name, description: "Keep notes", ...extra },
213 rw,
214 );
296 for (const res of unpicked) {
215297 expect(res.status).toBe(201);
216298 expect(((await res.json()) as { plan: unknown }).plan).toBeNull();
217299 }
@@ -253,18 +335,9 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
253335 });
254336
255337 test("POST /apps makes the app, puts the agent on the crew, assigns the first issue", async () => {
256 const made = await post(
257 "/api/v1/apps",
258 {
259 name: "hello",
260 description: "Keep notes",
261 visibility: "public",
262 agent: "bot",
263 },
264 rw,
265 );
338 const made = await hello();
266339 expect(made.status).toBe(201);
267 const body = (await made.json()) as {
340 const body = made.body as {
268341 app: { slug: string; url: string };
269342 issue: { number: number; assigneeName: string };
270343 };
@@ -279,11 +352,11 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
279352 const apps = (await (
280353 await api("/api/v1/apps", { token: null })
281354 ).json()) as { items: { slug: string }[] };
282 expect(apps.items.map((a) => a.slug)).toEqual(["hello"]);
355 expect(apps.items.map((a) => a.slug)).toContain("hello");
283356 // The assignment dispatched an agent run. With no Claude Code to run,
284357 // it cloned, branched, and failed with the reason — never silently.
285358 expect(crew.ready).toBe(false);
286 const run = await untilRun((r) => r.kind === "agent");
359 const run = await untilRun("hello", (r) => r.kind === "agent");
287360 expect(run.reason).toBe("assigned");
288361 expect(run.state).toBe("failed");
289362 expect(String(run.summary)).toContain("failed");
@@ -295,14 +368,15 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
295368 const tokens = app.db
296369 .query<
297370 { n: number },
298 []
299 >("SELECT COUNT(*) AS n FROM token WHERE name LIKE 'run:%'")
300 .get();
371 [string]
372 >("SELECT COUNT(*) AS n FROM token WHERE name = ?1")
373 .get(`run:${run.id}`);
301374 expect(tokens?.n ?? 0).toBe(0);
302375 }, 60_000);
303376
304377 test("the production run, its log as text and as a live tail that outlives the run itself (#284)", async () => {
305 const run = await untilRun((r) => r.env === "production");
378 expect((await hello()).status).toBe(201);
379 const run = await untilRun("hello", (r) => r.env === "production");
306380 expect(run.state).toBe("ok");
307381 const view = (await (
308382 await api("/api/v1/repos/alice/hello/app")
@@ -365,36 +439,37 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
365439 });
366440
367441 test("open, access, redeploy and stop", async () => {
442 await servingApp("opened");
368443 const open = (await (
369 await api("/api/v1/repos/alice/hello/app/production/open", {
444 await api("/api/v1/repos/alice/opened/app/production/open", {
370445 token: null,
371446 })
372447 ).json()) as { url: string };
373 expect(open.url).toBe(`http://hello.apps.test:${servers.port}`);
374 expect((await api("/api/v1/repos/alice/hello/app/nope/open")).status).toBe(
448 expect(open.url).toBe(`http://opened.apps.test:${servers.port}`);
449 expect((await api("/api/v1/repos/alice/opened/app/nope/open")).status).toBe(
375450 422,
376451 );
377452
378 const patched = await api("/api/v1/repos/alice/hello/app", {
453 const patched = await api("/api/v1/repos/alice/opened/app", {
379454 method: "PATCH",
380455 body: JSON.stringify({ access: "private" }),
381456 });
382457 expect(patched.status).toBe(200);
383458 expect(
384459 (
385 await api("/api/v1/repos/alice/hello/app", {
460 await api("/api/v1/repos/alice/opened/app", {
386461 method: "PATCH",
387462 body: JSON.stringify({ access: "public" }),
388463 token: rw,
389464 })
390465 ).status,
391466 ).toBe(403);
392 const anon = await api("/api/v1/repos/alice/hello/app/production/open", {
467 const anon = await api("/api/v1/repos/alice/opened/app/production/open", {
393468 token: null,
394469 });
395470 expect(anon.status).toBe(401);
396471 const granted = (await (
397 await api("/api/v1/repos/alice/hello/app/production/open")
472 await api("/api/v1/repos/alice/opened/app/production/open")
398473 ).json()) as { url: string };
399474 expect(granted.url).toContain("/__forge/grant?t=");
400475
@@ -402,12 +477,12 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
402477 // (#284) — but only while it still serves: stopping the environment is
403478 // what actually retires it, and the stream must close then.
404479 const runs = (await (
405 await api("/api/v1/repos/alice/hello/runs?env=production&limit=1")
480 await api("/api/v1/repos/alice/opened/runs?env=production&limit=1")
406481 ).json()) as { items: { id: string }[] };
407482 const liveRunId = runs.items[0]!.id;
408483 const controller = new AbortController();
409484 const tail = await api(
410 `/api/v1/repos/alice/hello/runs/${liveRunId}/log?follow=1`,
485 `/api/v1/repos/alice/opened/runs/${liveRunId}/log?follow=1`,
411486 { signal: controller.signal },
412487 );
413488 const reader = tail.body!.getReader();
@@ -436,7 +511,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
436511 expect(buf).not.toContain("event: done");
437512
438513 const stopped = await post(
439 "/api/v1/repos/alice/hello/app/production/stop",
514 "/api/v1/repos/alice/opened/app/production/stop",
440515 {},
441516 rw,
442517 );
@@ -447,27 +522,20 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
447522 reader.cancel().catch(() => {});
448523 controller.abort();
449524 const redeployed = await post(
450 "/api/v1/repos/alice/hello/app/production/redeploy",
525 "/api/v1/repos/alice/opened/app/production/redeploy",
451526 {},
452527 rw,
453528 );
454529 expect(redeployed.status).toBe(202);
455 const again = await untilRun((r) => r.reason === "redeploy");
530 const again = await untilRun("opened", (r) => r.reason === "redeploy");
456531 expect(again.state).toBe("ok");
457532 }, 90_000);
458533
459534 test("the app's environment: write-only values, handed to the process, admin only", async () => {
460 // Public again, so the proxy answers without a grant.
461 expect(
462 (
463 await api("/api/v1/repos/alice/hello/app", {
464 method: "PATCH",
465 body: JSON.stringify({ access: "public" }),
466 })
467 ).status,
468 ).toBe(200);
535 // Public, so the proxy answers without a grant.
536 await servingApp("envy");
469537 const alice = getUserByName(app.db, "alice")!;
470 const repo = getRepo(app.db, "alice", "hello")!;
538 const repo = getRepo(app.db, "alice", "envy")!;
471539 // An app that prints one variable, and nothing else.
472540 const committed = await commitToBranch(app, alice, repo, {
473541 message: "Print a variable",
@@ -496,18 +564,19 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
496564 expect(committed.ok).toBe(true);
497565 if (!committed.ok) return;
498566 const pushed = await untilRun(
567 "envy",
499568 (r) =>
500569 r.env === "production" && r.commitOid === committed.value.commitOid,
501570 );
502571 expect(pushed.state).toBe("ok");
503572 const viaApp = (path: string) =>
504 fetch(`${servers.url}${path}`, { headers: { host: "hello.apps.test" } });
573 fetch(`${servers.url}${path}`, { headers: { host: "envy.apps.test" } });
505574 expect(await (await viaApp("/env")).text()).toBe("unset");
506575 /** The newest redeploy after `after`, once it has settled. */
507576 const redeployAfter = (after: unknown) =>
508 untilRun((r) => r.reason === "redeploy" && String(r.id) > String(after));
577 untilRun("envy", (r) => r.reason === "redeploy" && String(r.id) > String(after));
509578
510 const env = "/api/v1/repos/alice/hello/app/env";
579 const env = "/api/v1/repos/alice/envy/app/env";
511580 const put = (name: string, value: unknown, token?: string | null) =>
512581 api(`${env}/${name}`, {
513582 method: "PUT",
@@ -614,7 +683,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
614683 expect(redeployed.state).toBe("ok");
615684 expect(await (await viaApp("/env")).text()).toBe(secret);
616685 const log = await (
617 await api(`/api/v1/repos/alice/hello/runs/${redeployed.id}/log`)
686 await api(`/api/v1/repos/alice/envy/runs/${redeployed.id}/log`)
618687 ).text();
619688 expect(log).not.toContain(secret);
620689
@@ -639,7 +708,8 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
639708 }, 120_000);
640709
641710 test("assignee: people and crew, and the crew grant is the only way in for an agent", async () => {
642 const cleared = await api("/api/v1/repos/alice/hello/issues/1/assignee", {
711 await servingApp("crewed");
712 const cleared = await api("/api/v1/repos/alice/crewed/issues/1/assignee", {
643713 method: "PUT",
644714 body: JSON.stringify({ assignee: null }),
645715 token: rw,
@@ -648,7 +718,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
648718 expect(
649719 ((await cleared.json()) as { assigneeName: string | null }).assigneeName,
650720 ).toBeNull();
651 const nobody = await api("/api/v1/repos/alice/hello/issues/1/assignee", {
721 const nobody = await api("/api/v1/repos/alice/crewed/issues/1/assignee", {
652722 method: "PUT",
653723 body: JSON.stringify({ assignee: "ghost" }),
654724 token: rw,
@@ -659,17 +729,17 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
659729 );
660730 expect(
661731 (
662 await api("/api/v1/repos/alice/hello/crew/bot", {
732 await api("/api/v1/repos/alice/crewed/crew/bot", {
663733 method: "DELETE",
664734 token: rw,
665735 })
666736 ).status,
667737 ).toBe(403);
668738 expect(
669 (await api("/api/v1/repos/alice/hello/crew/bot", { method: "DELETE" }))
739 (await api("/api/v1/repos/alice/crewed/crew/bot", { method: "DELETE" }))
670740 .status,
671741 ).toBe(200);
672 const off = await api("/api/v1/repos/alice/hello/issues/1/assignee", {
742 const off = await api("/api/v1/repos/alice/crewed/issues/1/assignee", {
673743 method: "PUT",
674744 body: JSON.stringify({ assignee: "bot" }),
675745 token: rw,
@@ -679,16 +749,16 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
679749 "agent_not_on_crew",
680750 );
681751 expect(
682 (await api("/api/v1/repos/alice/hello/crew/bot", { method: "PUT" }))
752 (await api("/api/v1/repos/alice/crewed/crew/bot", { method: "PUT" }))
683753 .status,
684754 ).toBe(200);
685 const on = await api("/api/v1/repos/alice/hello/issues/1/assignee", {
755 const on = await api("/api/v1/repos/alice/crewed/issues/1/assignee", {
686756 method: "PUT",
687757 body: JSON.stringify({ assignee: "bot" }),
688758 token: rw,
689759 });
690760 expect(on.status).toBe(200);
691 const mine = await api("/api/v1/repos/alice/hello/issues/1/assignee", {
761 const mine = await api("/api/v1/repos/alice/crewed/issues/1/assignee", {
692762 method: "PUT",
693763 body: JSON.stringify({ assignee: "alice" }),
694764 token: rw,
@@ -700,13 +770,14 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
700770 await api("/api/v1/user/assigned", { token: rw })
701771 ).json()) as { items: { number: number; repo: { name: string } }[] };
702772 expect(assigned.items.map((i) => `${i.repo.name}#${i.number}`)).toEqual([
703 "hello#1",
773 "crewed#1",
704774 ]);
705775 });
706776
707777 test("checks on a pull, and require_checks in the repo's settings", async () => {
778 await servingApp("checked");
708779 const alice = getUserByName(app.db, "alice")!;
709 const repo = getRepo(app.db, "alice", "hello")!;
780 const repo = getRepo(app.db, "alice", "checked")!;
710781 const committed = await commitToBranch(app, alice, repo, {
711782 branch: "change",
712783 base: repo.defaultBranch,
@@ -721,10 +792,10 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
721792 });
722793 expect(pull.ok).toBe(true);
723794 if (!pull.ok || !committed.ok) return;
724 const run = await untilRun((r) => r.env === `pull-${pull.value.number}`);
795 const run = await untilRun("checked", (r) => r.env === `pull-${pull.value.number}`);
725796 expect(run.state).toBe("ok");
726797 const checks = (await (
727 await api(`/api/v1/repos/alice/hello/pulls/${pull.value.number}/checks`)
798 await api(`/api/v1/repos/alice/checked/pulls/${pull.value.number}/checks`)
728799 ).json()) as {
729800 head: string;
730801 state: string;
@@ -734,7 +805,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
734805 expect(checks.head).toBe(committed.value.commitOid);
735806 expect(checks.state).toBe("ok");
736807 expect(checks.required).toBe(false);
737 const settings = await api("/api/v1/repos/alice/hello", {
808 const settings = await api("/api/v1/repos/alice/checked", {
738809 method: "PATCH",
739810 body: JSON.stringify({ requireChecks: true }),
740811 });
@@ -745,7 +816,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
745816 (
746817 (await (
747818 await api(
748 `/api/v1/repos/alice/hello/pulls/${pull.value.number}/checks`,
819 `/api/v1/repos/alice/checked/pulls/${pull.value.number}/checks`,
749820 )
750821 ).json()) as { required: boolean }
751822 ).required,
@@ -753,11 +824,12 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
753824 }, 90_000);
754825
755826 test("the app carries firstPreviewMs once a preview has served, and not before", async () => {
827 expect((await hello()).status).toBe(201);
756828 const read = async () =>
757829 (await (await api("/api/v1/repos/alice/hello/app")).json()) as { firstPreviewMs?: number };
758 app.db.query("UPDATE run SET serving_at = NULL WHERE env LIKE 'pull-%'").run();
759 expect((await read()).firstPreviewMs).toBeUndefined();
760830 const repoId = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'hello'").get()!.id;
831 app.db.query("UPDATE run SET serving_at = NULL WHERE env LIKE 'pull-%' AND repo_id = ?1").run(repoId);
832 expect((await read()).firstPreviewMs).toBeUndefined();
761833 app.db
762834 .query(
763835 `INSERT INTO run (id, repo_id, kind, env, state, created_at, serving_at)
@@ -769,48 +841,51 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
769841 });
770842
771843 test("a private repo's app is not_found to strangers; disabling removes it", async () => {
772 await api("/api/v1/repos/alice/hello", {
844 await servingApp("hidden");
845 expect(
846 (await fetch(`${servers.url}/`, { headers: { host: "hidden.apps.test" } }))
847 .status,
848 ).toBe(200);
849 await api("/api/v1/repos/alice/hidden", {
773850 method: "PATCH",
774851 body: JSON.stringify({ visibility: "private" }),
775852 });
776853 expect(
777 (await api("/api/v1/repos/alice/hello/app", { token: null })).status,
854 (await api("/api/v1/repos/alice/hidden/app", { token: null })).status,
778855 ).toBe(404);
779856 expect((await api("/api/v1/apps", { token: null })).ok).toBe(true);
780857 expect(
781858 (
782859 (await (await api("/api/v1/apps", { token: null })).json()) as {
783 items: unknown[];
860 items: { slug: string }[];
784861 }
785 ).items.length,
786 ).toBe(0);
862 ).items.map((a) => a.slug),
863 ).not.toContain("hidden");
787864 expect(
788865 (
789 await api("/api/v1/repos/alice/hello/app", {
866 await api("/api/v1/repos/alice/hidden/app", {
790867 method: "DELETE",
791868 token: rw,
792869 })
793870 ).status,
794871 ).toBe(403);
795872 expect(
796 (await api("/api/v1/repos/alice/hello/app", { method: "DELETE" })).status,
873 (await api("/api/v1/repos/alice/hidden/app", { method: "DELETE" })).status,
797874 ).toBe(200);
798 expect((await api("/api/v1/repos/alice/hello/app")).status).toBe(404);
799 const deadline = Date.now() + 5_000;
800 while (
801 (await fetch(`${servers.url}/`, { headers: { host: "hello.apps.test" } }))
802 .status !== 404 &&
803 Date.now() < deadline
804 )
805 await Bun.sleep(50);
875 expect((await api("/api/v1/repos/alice/hidden/app")).status).toBe(404);
876 // The proxy reads the app's row on every request, and the DELETE took
877 // it before answering: the host is gone at once, no waiting.
806878 expect(
807 (await fetch(`${servers.url}/`, { headers: { host: "hello.apps.test" } }))
879 (await fetch(`${servers.url}/`, { headers: { host: "hidden.apps.test" } }))
808880 .status,
809881 ).toBe(404);
810882 });
811883
812884 test("agents: update and retire", async () => {
813 const patched = await api("/api/v1/agents/bot", {
885 expect(
886 (await post("/api/v1/agents", { username: "retiree", budgetUsd: 1 })).status,
887 ).toBe(201);
888 const patched = await api("/api/v1/agents/retiree", {
814889 method: "PATCH",
815890 body: JSON.stringify({ budgetUsd: 2, model: "sonnet" }),
816891 });
@@ -818,11 +893,11 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
818893 ((await patched.json()) as { budgetUsd: number; model: string }).model,
819894 ).toBe("sonnet");
820895 expect(
821 (await api("/api/v1/agents/bot", { method: "DELETE", token: rw })).status,
896 (await api("/api/v1/agents/retiree", { method: "DELETE", token: rw })).status,
822897 ).toBe(403);
823 expect((await api("/api/v1/agents/bot", { method: "DELETE" })).status).toBe(
898 expect((await api("/api/v1/agents/retiree", { method: "DELETE" })).status).toBe(
824899 200,
825900 );
826 expect((await api("/api/v1/agents/bot")).status).toBe(404);
901 expect((await api("/api/v1/agents/retiree")).status).toBe(404);
827902 });
828903 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs