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

A test pins the cost line on /apps: today's dollars only for apps the viewer can write, and nothing for anyone else #6890

Merged opened this

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

Merged

scout/forge:scout/6874-a-test-pins-the-cost-line-on-apps-today → main 1 commit fork

  • Merged. scout/6874-a-test-pins-the-cost-line-on-apps-today was merged into main as 02087d5.

Review

Sign in to review this change.
1 file changed +156 −0
test/apps-cost-line.test.ts added +156
@@ -0,0 +1,156 @@
1 /**
2 * The cost line on /apps (#6874): today's dollars across only the apps the
3 * viewer can write, how many sit paused at their ceiling, and nothing — no
4 * sentence, no figure — for a guest or a reader who writes no app. Runs are
5 * created now, so "today" holds without the test reading the clock itself.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { enableApp } from "../src/actions/apps.ts";
14 import { addToCrew, createAgent } from "../src/actions/crew.ts";
15 import {
16 commitToBranch,
17 createRepo,
18 updateRepoSettings,
19 } from "../src/actions/repos.ts";
20 import { claimRun, createRun, noteRunProgress } from "../src/core/runs.ts";
21 import { createUser, getUserByName, type User } from "../src/core/users.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import { promote } from "./helpers/members.ts";
24
25 describe("the cost line on /apps", () => {
26 let dataDir: string;
27 let app: App;
28 let servers: Servers;
29 let base: string;
30 const cookies: Record<string, string> = {};
31
32 const person = async (username: string, adminIfFirst = false): Promise<User> => {
33 const u = await createUser(app.db, {
34 origin: "seed",
35 trusted: true,
36 username,
37 email: `${username}@forge.local`,
38 password: "a-good-passphrase",
39 ...(adminIfFirst ? { adminIfFirst } : {}),
40 });
41 if (!u.ok) throw new Error(u.error);
42 promote(app.db, u.value);
43 cookies[username] = (
44 (
45 await fetch(`${base}/signin`, {
46 method: "POST",
47 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
48 redirect: "manual",
49 })
50 ).headers.get("set-cookie") ?? ""
51 ).split(";")[0]!;
52 return u.value;
53 };
54
55 /** A public app owned by `owner` (the admin makes its agent), with one crew run costing `cost` today;
56 * `ceiling` turns autonomy on with that daily ceiling. */
57 const appWithSpend = async (
58 admin: User,
59 owner: User,
60 name: string,
61 cost: number,
62 ceiling?: number,
63 ) => {
64 const made = await createRepo(app, owner, {
65 name,
66 visibility: "public",
67 description: `${name} app`,
68 });
69 if (!made.ok) throw new Error(String(made.error));
70 const repo = made.value;
71 const seeded = await commitToBranch(app, owner, repo, {
72 message: "Start",
73 files: [{ path: "README.md", content: `${name}\n` }],
74 });
75 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
76 const enabled = await enableApp(app, owner, repo, { slug: name });
77 if (!enabled.ok) throw new Error(String(enabled.error));
78 const agent = `${name}-bot`;
79 const created = await createAgent(app, admin, { username: agent });
80 if (!created.ok) throw new Error(String(created.error));
81 expect(addToCrew(app, owner, repo, agent).ok).toBe(true);
82 if (ceiling !== undefined) {
83 const on = await updateRepoSettings(app, owner, repo, {
84 autonomy: "assisted",
85 autonomyDirection: "",
86 autonomyCeilingUsd: ceiling,
87 autonomySlots: 2,
88 autonomyBy: owner.id,
89 });
90 expect(on.ok).toBe(true);
91 }
92 const run = createRun(app.db, {
93 repoId: repo.id,
94 kind: "agent",
95 actorId: getUserByName(app.db, agent)!.id,
96 reason: "assigned",
97 });
98 claimRun(app.db, run.id);
99 noteRunProgress(app.db, run.id, { costUsd: cost });
100 };
101
102 const apps = (cookie?: string) =>
103 fetch(`${base}/apps`, {
104 headers: cookie ? { cookie: `${cookie}; forge_mode=studio` } : {},
105 redirect: "manual",
106 }).then((r) => r.text());
107
108 beforeAll(async () => {
109 dataDir = await mkdtemp(join(tmpdir(), "forge-apps-cost-line-"));
110 app = createApp({ dataDir, port: 0 });
111 servers = await serve(app);
112 base = servers.url;
113 const boss = await person("boss", true); // an admin writes every app: only makes agents
114 const alice = await person("alice");
115 const dave = await person("dave");
116 await person("carol"); // writes no app
117 await appWithSpend(boss, alice, "beacon", 6, 5); // paused: $6 >= $5
118 await appWithSpend(boss, alice, "loom", 2); // no autonomy: never paused
119 await appWithSpend(boss, dave, "other", 3.5); // alice cannot write it
120 });
121
122 afterAll(async () => {
123 servers.stop();
124 app.close();
125 await rm(dataDir, { recursive: true, force: true });
126 });
127
128 test("the owner reads the sum of only their own two apps and the one paused", async () => {
129 const text = (await apps(cookies.alice)).replace(/\s+/g, " ");
130 expect(text).toContain(
131 "$8.00 spent today across 2 apps you can write · 1 paused at <a href=\"/board\">their ceiling</a>",
132 );
133 expect(text).not.toContain("$11.50");
134 });
135
136 test("a guest and a reader who writes no app see no cost line and no dollars", async () => {
137 for (const html of [await apps(), await apps(cookies.carol)]) {
138 expect(html).not.toContain("spent today");
139 expect(html).not.toContain("studio-cost");
140 expect(html).not.toMatch(/\$\d/);
141 }
142 });
143
144 test("one app reads '1 app', and the ceiling link answers the owner 200", async () => {
145 const html = (await apps(cookies.dave)).replace(/\s+/g, " ");
146 expect(html).toContain("$3.50 spent today across 1 app you can write · 0 paused");
147 expect(html).not.toContain("1 apps");
148 const href = /<a href="([^"]+)">their ceiling<\/a>/.exec(html)?.[1];
149 expect(href).toBe("/board");
150 const res = await fetch(`${base}${href}`, {
151 headers: { cookie: `${cookies.dave}; forge_mode=studio` },
152 redirect: "manual",
153 });
154 expect(res.status).toBe(200);
155 });
156 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs