Public: anyone can read this; the crew can change it.
test/apps-api.test.ts runs in under 8 s #11060
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11054-test-apps-api-test-ts-runs-in-under-8-s → main 1 commit fork
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", () => { | ||
| 77 | 77 | ...(token === undefined ? {} : { token }), |
| 78 | 78 | }); |
| 79 | 79 | |
| 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 | } | |
| 90 | 115 | } |
| 91 | throw new Error("run did not settle"); | |
| 92 | 116 | } |
| 93 | 117 | |
| 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 | ||
| 94 | 169 | beforeAll(async () => { |
| 95 | 170 | dataDir = await mkdtemp(join(tmpdir(), "forge-apps-api-")); |
| 96 | 171 | app = createApp({ dataDir, port: 0 }); |
| @@ -111,6 +186,9 @@ describe("apps, runs and the crew over the API", () => { | ||
| 111 | 186 | concurrency: 1, |
| 112 | 187 | budgetUsd: 1, |
| 113 | 188 | 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, | |
| 114 | 192 | }); |
| 115 | 193 | await seedAdmin(app, { |
| 116 | 194 | username: "alice", |
| @@ -135,6 +213,12 @@ describe("apps, runs and the crew over the API", () => { | ||
| 135 | 213 | ).token; |
| 136 | 214 | admin = await mint("admin"); |
| 137 | 215 | 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(); | |
| 138 | 222 | }, 30_000); |
| 139 | 223 | |
| 140 | 224 | afterAll(async () => { |
| @@ -146,10 +230,10 @@ describe("apps, runs and the crew over the API", () => { | ||
| 146 | 230 | }); |
| 147 | 231 | |
| 148 | 232 | 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); | |
| 150 | 234 | expect(refused.status).toBe(403); |
| 151 | 235 | const made = await post("/api/v1/agents", { |
| 152 | username: "bot", | |
| 236 | username: "maker", | |
| 153 | 237 | brief: "Be brief.", |
| 154 | 238 | budgetUsd: 1, |
| 155 | 239 | }); |
| @@ -158,18 +242,18 @@ describe("apps, runs and the crew over the API", () => { | ||
| 158 | 242 | username: string; |
| 159 | 243 | budgetUsd: number; |
| 160 | 244 | }; |
| 161 | expect(agent.username).toBe("bot"); | |
| 245 | expect(agent.username).toBe("maker"); | |
| 162 | 246 | expect(agent.budgetUsd).toBe(1); |
| 163 | 247 | const listed = (await ( |
| 164 | 248 | await api("/api/v1/agents", { token: rw }) |
| 165 | 249 | ).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"); | |
| 167 | 251 | expect((await api("/api/v1/agents", { token: null })).status).toBe(401); |
| 168 | 252 | // 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"); | |
| 170 | 254 | const login = await post( |
| 171 | 255 | "/api/v1/tokens", |
| 172 | { username: "bot", password: "", name: "x", scope: "rw" }, | |
| 256 | { username: "maker", password: "", name: "x", scope: "rw" }, | |
| 173 | 257 | null, |
| 174 | 258 | ); |
| 175 | 259 | expect(login.status).toBe(401); |
| @@ -188,30 +272,28 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 188 | 272 | const before = process.env["FORGE_CLAUDE"]; |
| 189 | 273 | try { |
| 190 | 274 | 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)), | |
| 195 | 287 | ); |
| 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 { | |
| 198 | 290 | plan: { agent: string; summary: string; confidence: number } | null; |
| 199 | 291 | issue: { assigneeName: string } | null; |
| 200 | 292 | }; |
| 201 | 293 | expect(body.plan).toEqual({ agent: "bot", summary: "Build it", confidence: 0.7 }); |
| 202 | 294 | expect(body.issue?.assigneeName).toBe("bot"); |
| 203 | 295 | |
| 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) { | |
| 215 | 297 | expect(res.status).toBe(201); |
| 216 | 298 | expect(((await res.json()) as { plan: unknown }).plan).toBeNull(); |
| 217 | 299 | } |
| @@ -253,18 +335,9 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 253 | 335 | }); |
| 254 | 336 | |
| 255 | 337 | 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(); | |
| 266 | 339 | expect(made.status).toBe(201); |
| 267 | const body = (await made.json()) as { | |
| 340 | const body = made.body as { | |
| 268 | 341 | app: { slug: string; url: string }; |
| 269 | 342 | issue: { number: number; assigneeName: string }; |
| 270 | 343 | }; |
| @@ -279,11 +352,11 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 279 | 352 | const apps = (await ( |
| 280 | 353 | await api("/api/v1/apps", { token: null }) |
| 281 | 354 | ).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"); | |
| 283 | 356 | // The assignment dispatched an agent run. With no Claude Code to run, |
| 284 | 357 | // it cloned, branched, and failed with the reason — never silently. |
| 285 | 358 | expect(crew.ready).toBe(false); |
| 286 | const run = await untilRun((r) => r.kind === "agent"); | |
| 359 | const run = await untilRun("hello", (r) => r.kind === "agent"); | |
| 287 | 360 | expect(run.reason).toBe("assigned"); |
| 288 | 361 | expect(run.state).toBe("failed"); |
| 289 | 362 | expect(String(run.summary)).toContain("failed"); |
| @@ -295,14 +368,15 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 295 | 368 | const tokens = app.db |
| 296 | 369 | .query< |
| 297 | 370 | { 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}`); | |
| 301 | 374 | expect(tokens?.n ?? 0).toBe(0); |
| 302 | 375 | }, 60_000); |
| 303 | 376 | |
| 304 | 377 | 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"); | |
| 306 | 380 | expect(run.state).toBe("ok"); |
| 307 | 381 | const view = (await ( |
| 308 | 382 | await api("/api/v1/repos/alice/hello/app") |
| @@ -365,36 +439,37 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 365 | 439 | }); |
| 366 | 440 | |
| 367 | 441 | test("open, access, redeploy and stop", async () => { |
| 442 | await servingApp("opened"); | |
| 368 | 443 | 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", { | |
| 370 | 445 | token: null, |
| 371 | 446 | }) |
| 372 | 447 | ).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( | |
| 375 | 450 | 422, |
| 376 | 451 | ); |
| 377 | 452 | |
| 378 | const patched = await api("/api/v1/repos/alice/hello/app", { | |
| 453 | const patched = await api("/api/v1/repos/alice/opened/app", { | |
| 379 | 454 | method: "PATCH", |
| 380 | 455 | body: JSON.stringify({ access: "private" }), |
| 381 | 456 | }); |
| 382 | 457 | expect(patched.status).toBe(200); |
| 383 | 458 | expect( |
| 384 | 459 | ( |
| 385 | await api("/api/v1/repos/alice/hello/app", { | |
| 460 | await api("/api/v1/repos/alice/opened/app", { | |
| 386 | 461 | method: "PATCH", |
| 387 | 462 | body: JSON.stringify({ access: "public" }), |
| 388 | 463 | token: rw, |
| 389 | 464 | }) |
| 390 | 465 | ).status, |
| 391 | 466 | ).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", { | |
| 393 | 468 | token: null, |
| 394 | 469 | }); |
| 395 | 470 | expect(anon.status).toBe(401); |
| 396 | 471 | 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") | |
| 398 | 473 | ).json()) as { url: string }; |
| 399 | 474 | expect(granted.url).toContain("/__forge/grant?t="); |
| 400 | 475 | |
| @@ -402,12 +477,12 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 402 | 477 | // (#284) — but only while it still serves: stopping the environment is |
| 403 | 478 | // what actually retires it, and the stream must close then. |
| 404 | 479 | 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") | |
| 406 | 481 | ).json()) as { items: { id: string }[] }; |
| 407 | 482 | const liveRunId = runs.items[0]!.id; |
| 408 | 483 | const controller = new AbortController(); |
| 409 | 484 | 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`, | |
| 411 | 486 | { signal: controller.signal }, |
| 412 | 487 | ); |
| 413 | 488 | const reader = tail.body!.getReader(); |
| @@ -436,7 +511,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 436 | 511 | expect(buf).not.toContain("event: done"); |
| 437 | 512 | |
| 438 | 513 | const stopped = await post( |
| 439 | "/api/v1/repos/alice/hello/app/production/stop", | |
| 514 | "/api/v1/repos/alice/opened/app/production/stop", | |
| 440 | 515 | {}, |
| 441 | 516 | rw, |
| 442 | 517 | ); |
| @@ -447,27 +522,20 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 447 | 522 | reader.cancel().catch(() => {}); |
| 448 | 523 | controller.abort(); |
| 449 | 524 | const redeployed = await post( |
| 450 | "/api/v1/repos/alice/hello/app/production/redeploy", | |
| 525 | "/api/v1/repos/alice/opened/app/production/redeploy", | |
| 451 | 526 | {}, |
| 452 | 527 | rw, |
| 453 | 528 | ); |
| 454 | 529 | expect(redeployed.status).toBe(202); |
| 455 | const again = await untilRun((r) => r.reason === "redeploy"); | |
| 530 | const again = await untilRun("opened", (r) => r.reason === "redeploy"); | |
| 456 | 531 | expect(again.state).toBe("ok"); |
| 457 | 532 | }, 90_000); |
| 458 | 533 | |
| 459 | 534 | 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"); | |
| 469 | 537 | const alice = getUserByName(app.db, "alice")!; |
| 470 | const repo = getRepo(app.db, "alice", "hello")!; | |
| 538 | const repo = getRepo(app.db, "alice", "envy")!; | |
| 471 | 539 | // An app that prints one variable, and nothing else. |
| 472 | 540 | const committed = await commitToBranch(app, alice, repo, { |
| 473 | 541 | message: "Print a variable", |
| @@ -496,18 +564,19 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 496 | 564 | expect(committed.ok).toBe(true); |
| 497 | 565 | if (!committed.ok) return; |
| 498 | 566 | const pushed = await untilRun( |
| 567 | "envy", | |
| 499 | 568 | (r) => |
| 500 | 569 | r.env === "production" && r.commitOid === committed.value.commitOid, |
| 501 | 570 | ); |
| 502 | 571 | expect(pushed.state).toBe("ok"); |
| 503 | 572 | const viaApp = (path: string) => |
| 504 | fetch(`${servers.url}${path}`, { headers: { host: "hello.apps.test" } }); | |
| 573 | fetch(`${servers.url}${path}`, { headers: { host: "envy.apps.test" } }); | |
| 505 | 574 | expect(await (await viaApp("/env")).text()).toBe("unset"); |
| 506 | 575 | /** The newest redeploy after `after`, once it has settled. */ |
| 507 | 576 | 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)); | |
| 509 | 578 | |
| 510 | const env = "/api/v1/repos/alice/hello/app/env"; | |
| 579 | const env = "/api/v1/repos/alice/envy/app/env"; | |
| 511 | 580 | const put = (name: string, value: unknown, token?: string | null) => |
| 512 | 581 | api(`${env}/${name}`, { |
| 513 | 582 | method: "PUT", |
| @@ -614,7 +683,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 614 | 683 | expect(redeployed.state).toBe("ok"); |
| 615 | 684 | expect(await (await viaApp("/env")).text()).toBe(secret); |
| 616 | 685 | 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`) | |
| 618 | 687 | ).text(); |
| 619 | 688 | expect(log).not.toContain(secret); |
| 620 | 689 | |
| @@ -639,7 +708,8 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 639 | 708 | }, 120_000); |
| 640 | 709 | |
| 641 | 710 | 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", { | |
| 643 | 713 | method: "PUT", |
| 644 | 714 | body: JSON.stringify({ assignee: null }), |
| 645 | 715 | token: rw, |
| @@ -648,7 +718,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 648 | 718 | expect( |
| 649 | 719 | ((await cleared.json()) as { assigneeName: string | null }).assigneeName, |
| 650 | 720 | ).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", { | |
| 652 | 722 | method: "PUT", |
| 653 | 723 | body: JSON.stringify({ assignee: "ghost" }), |
| 654 | 724 | token: rw, |
| @@ -659,17 +729,17 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 659 | 729 | ); |
| 660 | 730 | expect( |
| 661 | 731 | ( |
| 662 | await api("/api/v1/repos/alice/hello/crew/bot", { | |
| 732 | await api("/api/v1/repos/alice/crewed/crew/bot", { | |
| 663 | 733 | method: "DELETE", |
| 664 | 734 | token: rw, |
| 665 | 735 | }) |
| 666 | 736 | ).status, |
| 667 | 737 | ).toBe(403); |
| 668 | 738 | 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" })) | |
| 670 | 740 | .status, |
| 671 | 741 | ).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", { | |
| 673 | 743 | method: "PUT", |
| 674 | 744 | body: JSON.stringify({ assignee: "bot" }), |
| 675 | 745 | token: rw, |
| @@ -679,16 +749,16 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 679 | 749 | "agent_not_on_crew", |
| 680 | 750 | ); |
| 681 | 751 | 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" })) | |
| 683 | 753 | .status, |
| 684 | 754 | ).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", { | |
| 686 | 756 | method: "PUT", |
| 687 | 757 | body: JSON.stringify({ assignee: "bot" }), |
| 688 | 758 | token: rw, |
| 689 | 759 | }); |
| 690 | 760 | 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", { | |
| 692 | 762 | method: "PUT", |
| 693 | 763 | body: JSON.stringify({ assignee: "alice" }), |
| 694 | 764 | token: rw, |
| @@ -700,13 +770,14 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 700 | 770 | await api("/api/v1/user/assigned", { token: rw }) |
| 701 | 771 | ).json()) as { items: { number: number; repo: { name: string } }[] }; |
| 702 | 772 | expect(assigned.items.map((i) => `${i.repo.name}#${i.number}`)).toEqual([ |
| 703 | "hello#1", | |
| 773 | "crewed#1", | |
| 704 | 774 | ]); |
| 705 | 775 | }); |
| 706 | 776 | |
| 707 | 777 | test("checks on a pull, and require_checks in the repo's settings", async () => { |
| 778 | await servingApp("checked"); | |
| 708 | 779 | const alice = getUserByName(app.db, "alice")!; |
| 709 | const repo = getRepo(app.db, "alice", "hello")!; | |
| 780 | const repo = getRepo(app.db, "alice", "checked")!; | |
| 710 | 781 | const committed = await commitToBranch(app, alice, repo, { |
| 711 | 782 | branch: "change", |
| 712 | 783 | base: repo.defaultBranch, |
| @@ -721,10 +792,10 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 721 | 792 | }); |
| 722 | 793 | expect(pull.ok).toBe(true); |
| 723 | 794 | 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}`); | |
| 725 | 796 | expect(run.state).toBe("ok"); |
| 726 | 797 | 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`) | |
| 728 | 799 | ).json()) as { |
| 729 | 800 | head: string; |
| 730 | 801 | state: string; |
| @@ -734,7 +805,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 734 | 805 | expect(checks.head).toBe(committed.value.commitOid); |
| 735 | 806 | expect(checks.state).toBe("ok"); |
| 736 | 807 | 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", { | |
| 738 | 809 | method: "PATCH", |
| 739 | 810 | body: JSON.stringify({ requireChecks: true }), |
| 740 | 811 | }); |
| @@ -745,7 +816,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 745 | 816 | ( |
| 746 | 817 | (await ( |
| 747 | 818 | await api( |
| 748 | `/api/v1/repos/alice/hello/pulls/${pull.value.number}/checks`, | |
| 819 | `/api/v1/repos/alice/checked/pulls/${pull.value.number}/checks`, | |
| 749 | 820 | ) |
| 750 | 821 | ).json()) as { required: boolean } |
| 751 | 822 | ).required, |
| @@ -753,11 +824,12 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 753 | 824 | }, 90_000); |
| 754 | 825 | |
| 755 | 826 | test("the app carries firstPreviewMs once a preview has served, and not before", async () => { |
| 827 | expect((await hello()).status).toBe(201); | |
| 756 | 828 | const read = async () => |
| 757 | 829 | (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(); | |
| 760 | 830 | 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(); | |
| 761 | 833 | app.db |
| 762 | 834 | .query( |
| 763 | 835 | `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 | ||
| 769 | 841 | }); |
| 770 | 842 | |
| 771 | 843 | 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", { | |
| 773 | 850 | method: "PATCH", |
| 774 | 851 | body: JSON.stringify({ visibility: "private" }), |
| 775 | 852 | }); |
| 776 | 853 | 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, | |
| 778 | 855 | ).toBe(404); |
| 779 | 856 | expect((await api("/api/v1/apps", { token: null })).ok).toBe(true); |
| 780 | 857 | expect( |
| 781 | 858 | ( |
| 782 | 859 | (await (await api("/api/v1/apps", { token: null })).json()) as { |
| 783 | items: unknown[]; | |
| 860 | items: { slug: string }[]; | |
| 784 | 861 | } |
| 785 | ).items.length, | |
| 786 | ).toBe(0); | |
| 862 | ).items.map((a) => a.slug), | |
| 863 | ).not.toContain("hidden"); | |
| 787 | 864 | expect( |
| 788 | 865 | ( |
| 789 | await api("/api/v1/repos/alice/hello/app", { | |
| 866 | await api("/api/v1/repos/alice/hidden/app", { | |
| 790 | 867 | method: "DELETE", |
| 791 | 868 | token: rw, |
| 792 | 869 | }) |
| 793 | 870 | ).status, |
| 794 | 871 | ).toBe(403); |
| 795 | 872 | 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, | |
| 797 | 874 | ).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. | |
| 806 | 878 | expect( |
| 807 | (await fetch(`${servers.url}/`, { headers: { host: "hello.apps.test" } })) | |
| 879 | (await fetch(`${servers.url}/`, { headers: { host: "hidden.apps.test" } })) | |
| 808 | 880 | .status, |
| 809 | 881 | ).toBe(404); |
| 810 | 882 | }); |
| 811 | 883 | |
| 812 | 884 | 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", { | |
| 814 | 889 | method: "PATCH", |
| 815 | 890 | body: JSON.stringify({ budgetUsd: 2, model: "sonnet" }), |
| 816 | 891 | }); |
| @@ -818,11 +893,11 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 818 | 893 | ((await patched.json()) as { budgetUsd: number; model: string }).model, |
| 819 | 894 | ).toBe("sonnet"); |
| 820 | 895 | 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, | |
| 822 | 897 | ).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( | |
| 824 | 899 | 200, |
| 825 | 900 | ); |
| 826 | expect((await api("/api/v1/agents/bot")).status).toBe(404); | |
| 901 | expect((await api("/api/v1/agents/retiree")).status).toBe(404); | |
| 827 | 902 | }); |
| 828 | 903 | }); |