Public: anyone can read this; the crew can change it.
The studio tells you to add an agent below, and there is nothing below #204
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/196-the-studio-tells-you-to-add-an-agent-bel → main 1 commit fork
2 files
changed +165
−5
src/web/studio/crew.ts +25 −5
| @@ -52,13 +52,33 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { | ||
| 52 | 52 | (agent) => !onCrew.has(agent.user.id), |
| 53 | 53 | ); |
| 54 | 54 | |
| 55 | const forgeAdmin = ctx.auth.user?.isAdmin === true; | |
| 55 | 56 | const rows = |
| 56 | 57 | crew.length === 0 |
| 57 | ? empty({ | |
| 58 | icon: "bot", | |
| 59 | title: "Nobody yet", | |
| 60 | note: "Add an agent below and tell it what you want.", | |
| 61 | }) | |
| 58 | ? empty( | |
| 59 | available.length > 0 | |
| 60 | ? { | |
| 61 | icon: "bot", | |
| 62 | title: "Nobody yet", | |
| 63 | note: "Add an agent below and tell it what you want.", | |
| 64 | } | |
| 65 | : forgeAdmin | |
| 66 | ? { | |
| 67 | icon: "bot", | |
| 68 | title: "Nobody yet", | |
| 69 | note: "No agents exist on this forge yet.", | |
| 70 | action: btn("Add an agent", { | |
| 71 | href: "/settings/crew", | |
| 72 | variant: "primary", | |
| 73 | icon: "plus", | |
| 74 | }), | |
| 75 | } | |
| 76 | : { | |
| 77 | icon: "bot", | |
| 78 | title: "Nobody yet", | |
| 79 | note: "No agents exist on this forge yet. An admin can add one.", | |
| 80 | }, | |
| 81 | ) | |
| 62 | 82 | : listCard( |
| 63 | 83 | crew.map((c) => |
| 64 | 84 | row({ |
test/studio.test.ts +140
| @@ -13,6 +13,7 @@ import { previewEnv, type EnvName } from "../src/apps/data.ts"; | ||
| 13 | 13 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; |
| 14 | 14 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; |
| 15 | 15 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 16 | import { addTeamMember, createOrg, createTeam } from "../src/actions/orgs.ts"; | |
| 16 | 17 | import { linkIssue } from "../src/actions/dependencies.ts"; |
| 17 | 18 | import { |
| 18 | 19 | assignIssue, |
| @@ -4357,6 +4358,145 @@ describe("a stranger sees the app, not the owner's books", () => { | ||
| 4357 | 4358 | }); |
| 4358 | 4359 | }); |
| 4359 | 4360 | |
| 4361 | describe("the studio's crew panel on a forge with no agents yet", () => { | |
| 4362 | let dataDir: string; | |
| 4363 | let app: App; | |
| 4364 | let servers: Servers; | |
| 4365 | let base: string; | |
| 4366 | let ownerCookie: string; | |
| 4367 | let teamAdminCookie: string; | |
| 4368 | ||
| 4369 | const signIn = async (username: string): Promise<string> => | |
| 4370 | ( | |
| 4371 | ( | |
| 4372 | await fetch(`${base}/signin`, { | |
| 4373 | method: "POST", | |
| 4374 | body: new URLSearchParams({ | |
| 4375 | username, | |
| 4376 | password: "a-good-passphrase", | |
| 4377 | }), | |
| 4378 | redirect: "manual", | |
| 4379 | }) | |
| 4380 | ).headers.get("set-cookie") ?? "" | |
| 4381 | ).split(";")[0]!; | |
| 4382 | ||
| 4383 | const get = (path: string, cookie: string) => | |
| 4384 | fetch(`${base}${path}`, { | |
| 4385 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 4386 | redirect: "manual", | |
| 4387 | }); | |
| 4388 | ||
| 4389 | beforeAll(async () => { | |
| 4390 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-no-agents-")); | |
| 4391 | app = createApp({ dataDir, port: 0 }); | |
| 4392 | servers = await serve(app); | |
| 4393 | base = servers.url; | |
| 4394 | const a = await createUser(app.db, { | |
| 4395 | username: "alice", | |
| 4396 | email: "[email protected]", | |
| 4397 | password: "a-good-passphrase", | |
| 4398 | adminIfFirst: true, | |
| 4399 | }); | |
| 4400 | if (!a.ok) throw new Error(a.error); | |
| 4401 | const alice = a.value; | |
| 4402 | const b = await createUser(app.db, { | |
| 4403 | username: "bob", | |
| 4404 | email: "[email protected]", | |
| 4405 | password: "a-good-passphrase", | |
| 4406 | }); | |
| 4407 | if (!b.ok) throw new Error(b.error); | |
| 4408 | ownerCookie = await signIn("alice"); | |
| 4409 | teamAdminCookie = await signIn("bob"); | |
| 4410 | ||
| 4411 | // alice's own app: she is both the repo's admin and the forge's. | |
| 4412 | const pad = await createRepo(app, alice, { | |
| 4413 | name: "pad", | |
| 4414 | visibility: "public", | |
| 4415 | }); | |
| 4416 | if (!pad.ok) throw new Error(String(pad.error)); | |
| 4417 | const padSeeded = await commitToBranch(app, alice, pad.value, { | |
| 4418 | message: "Start", | |
| 4419 | files: [{ path: "README.md", content: "pad\n" }], | |
| 4420 | }); | |
| 4421 | if (!padSeeded.ok) throw new Error(JSON.stringify(padSeeded.error)); | |
| 4422 | const padEnabled = await enableApp(app, alice, pad.value, { slug: "pad" }); | |
| 4423 | if (!padEnabled.ok) throw new Error(String(padEnabled.error)); | |
| 4424 | ||
| 4425 | // an org app whose team admin, bob, is not a forge admin. | |
| 4426 | const org = await createOrg(app, alice, "acme"); | |
| 4427 | if (!org.ok) throw new Error(org.error); | |
| 4428 | const team = await createTeam(app, alice, org.value, "leads", "admin"); | |
| 4429 | if (!team.ok) throw new Error(String(team.error)); | |
| 4430 | const added = await addTeamMember(app, alice, org.value, team.value, "bob"); | |
| 4431 | if (!added.ok) throw new Error(String(added.error)); | |
| 4432 | const vault = await createRepo(app, alice, { | |
| 4433 | name: "vault", | |
| 4434 | owner: "acme", | |
| 4435 | visibility: "public", | |
| 4436 | }); | |
| 4437 | if (!vault.ok) throw new Error(String(vault.error)); | |
| 4438 | const vaultSeeded = await commitToBranch(app, alice, vault.value, { | |
| 4439 | message: "Start", | |
| 4440 | files: [{ path: "README.md", content: "vault\n" }], | |
| 4441 | }); | |
| 4442 | if (!vaultSeeded.ok) throw new Error(JSON.stringify(vaultSeeded.error)); | |
| 4443 | const vaultEnabled = await enableApp(app, alice, vault.value, { | |
| 4444 | slug: "vault", | |
| 4445 | }); | |
| 4446 | if (!vaultEnabled.ok) throw new Error(String(vaultEnabled.error)); | |
| 4447 | }); | |
| 4448 | ||
| 4449 | afterAll(async () => { | |
| 4450 | servers.stop(); | |
| 4451 | app.close(); | |
| 4452 | await rm(dataDir, { recursive: true, force: true }); | |
| 4453 | }); | |
| 4454 | ||
| 4455 | test("a forge admin sees the crew panel name the door an agent is made through, and reaches it in one click without leaving studio mode", async () => { | |
| 4456 | const settings = await ( | |
| 4457 | await get("/alice/pad/settings", ownerCookie) | |
| 4458 | ).text(); | |
| 4459 | expect(settings).toContain("No agents exist on this forge yet."); | |
| 4460 | expect(settings).not.toContain("Add an agent below"); | |
| 4461 | expect(settings).toContain('href="/settings/crew"'); | |
| 4462 | ||
| 4463 | const doorResponse = await get("/settings/crew", ownerCookie); | |
| 4464 | expect(doorResponse.status).toBe(200); | |
| 4465 | const crewPage = await doorResponse.text(); | |
| 4466 | expect(crewPage).toContain("New agent"); | |
| 4467 | expect(crewPage).toContain('action="/settings/crew"'); | |
| 4468 | }); | |
| 4469 | ||
| 4470 | test("a repository admin who is not a forge admin is told who can add an agent, not sent to a page that refuses them", async () => { | |
| 4471 | const settings = await ( | |
| 4472 | await get("/acme/vault/settings", teamAdminCookie) | |
| 4473 | ).text(); | |
| 4474 | expect(settings).toContain( | |
| 4475 | "No agents exist on this forge yet. An admin can add one.", | |
| 4476 | ); | |
| 4477 | expect(settings).not.toContain('href="/settings/crew"'); | |
| 4478 | ||
| 4479 | const refused = await get("/settings/crew", teamAdminCookie); | |
| 4480 | expect(refused.status).toBe(404); | |
| 4481 | }); | |
| 4482 | ||
| 4483 | test("the app page's 'Add someone' leads unbroken to the door an agent is made through, with nothing dev-only in the way", async () => { | |
| 4484 | const appPage = await (await get("/alice/pad", ownerCookie)).text(); | |
| 4485 | expect(appPage).toContain("Nobody yet"); | |
| 4486 | expect(appPage).toContain('href="/alice/pad/settings">Add someone.'); | |
| 4487 | ||
| 4488 | const settings = await ( | |
| 4489 | await get("/alice/pad/settings", ownerCookie) | |
| 4490 | ).text(); | |
| 4491 | expect(settings).toContain('href="/settings/crew"'); | |
| 4492 | ||
| 4493 | const door = await get("/settings/crew", ownerCookie); | |
| 4494 | expect(door.status).toBe(200); | |
| 4495 | const doorBody = await door.text(); | |
| 4496 | expect(doorBody).toContain("New agent"); | |
| 4497 | }); | |
| 4498 | }); | |
| 4499 | ||
| 4360 | 4500 | /** |
| 4361 | 4501 | * The one function every page words a deployment's speed with. The pages |
| 4362 | 4502 | * above prove it reaches the studio; these prove what it says at the edges, |
new above
offline