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

One test proves a tell from the composer, the forge pill, a phone's share sheet and the MCP lands as the same kind of tell #7777

Merged opened this

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

Merged

scout/forge:scout/7752-one-test-proves-a-tell-from-the-composer → main 1 commit fork

  • Merged. scout/7752-one-test-proves-a-tell-from-the-composer was merged into main as 618ffcf.

Review

Sign in to review this change.
1 file changed +132 −0
test/steering-four-places.test.ts added +132
@@ -0,0 +1,132 @@
1 /**
2 * P7 Steering: a tell from the feed composer, the forge pill, a phone's share
3 * sheet and the MCP lands as the same kind of tell — one issue in the repo,
4 * the same kind and labels, the teller as author, and the crew's builder
5 * assigned (which is what starts a run).
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { addToCrew, createAgent } from "../src/actions/crew.ts";
12 import { newApp } from "../src/actions/apps.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { labelsFor, listIssues, type Issue } from "../src/core/issues.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { createUser } from "../src/core/users.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { mcpTool } from "./helpers/mcp.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { proveAll } from "./helpers/verified.ts";
22
23 quietLogs();
24
25 describe("a tell from the four places", () => {
26 let dir: string;
27 let app: App;
28 let servers: Servers;
29 let repo: Repo;
30 let token: string;
31
32 const issues = () => listIssues(app.db, repo.id, {});
33 const form = (path: string, fields: Record<string, string>, headers: Record<string, string> = {}) =>
34 fetch(`${servers.url}${path}`, {
35 method: "POST",
36 headers: { authorization: `Bearer ${token}`, origin: servers.url, ...headers },
37 body: new URLSearchParams(fields),
38 redirect: "manual",
39 });
40 /** The one issue a tell opened, found by its title. */
41 const opened = (title: string): Issue => {
42 const found = issues().filter((i) => i.title === title);
43 expect(found).toHaveLength(1);
44 return found[0]!;
45 };
46
47 beforeAll(async () => {
48 dir = await mkdtemp(join(tmpdir(), "forge-four-"));
49 app = createApp({ dataDir: dir, port: 0 });
50 servers = await serve(app);
51 const u = await createUser(app.db, {
52 origin: "seed",
53 trusted: true,
54 username: "alice",
55 email: "[email protected]",
56 password: "a-good-passphrase",
57 adminIfFirst: true,
58 });
59 if (!u.ok) throw new Error(u.error);
60 proveAll(app);
61 const r = await newApp(app, u.value, { name: "notes", description: "x", visibility: "public" });
62 if (!r.ok) throw new Error(String(r.error));
63 repo = r.value.repo;
64 const bot = await createAgent(app, u.value, { username: "bot", brief: "Be brief." });
65 if (!bot.ok) throw new Error(String(bot.error));
66 expect(addToCrew(app, u.value, repo, "bot").ok).toBe(true);
67 token = createToken(app.db, u.value.id, "rw", "rw").token;
68 }, 60_000);
69
70 afterAll(async () => {
71 servers.stop();
72 app.close();
73 await rm(dir, { recursive: true, force: true });
74 });
75
76 test("each place opens one issue, by alice, assigned to the crew", async () => {
77 const before = issues().length;
78
79 // 1. the feed composer's JSON door
80 const composer = await fetch(`${servers.url}/api/v1/feed/tells`, {
81 method: "POST",
82 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
83 body: JSON.stringify({ app: "alice/notes", text: "Tell from composer" }),
84 });
85 expect(composer.status).toBe(201);
86
87 // 2. the pill's form on the forge's own origin, with the app's page
88 const pill = await form("/alice/notes/tell", {
89 text: "Tell from pill",
90 page: "https://notes.apps.test/a",
91 });
92 expect(pill.status).toBe(200);
93
94 // 3. the share sheet: land on the composer with a draft, then post it
95 const landed = await fetch(`${servers.url}/?text=${encodeURIComponent("Tell from share")}`, {
96 headers: { authorization: `Bearer ${token}` },
97 });
98 expect(landed.status).toBe(200);
99 const page = await landed.text();
100 expect(page).toMatch(/<textarea[^>]*name="text"[^>]*>Tell from share<\/textarea>/);
101 expect(page).toContain('<option value="alice/notes">');
102 expect(issues().some((i) => i.title === "Tell from share")).toBe(false);
103 const crossOrigin = await form(
104 "/tell",
105 { app: "alice/notes", text: "Tell from share" },
106 { origin: "https://evil.test" },
107 );
108 expect(crossOrigin.status).toBeGreaterThanOrEqual(400);
109 expect(issues().some((i) => i.title === "Tell from share")).toBe(false);
110 const shared = await form("/tell", { app: "alice/notes", text: "Tell from share" });
111 expect(shared.status).toBeLessThan(400);
112
113 // 4. the MCP tell tool
114 const mcp = await mcpTool(servers.url, token, "tell", { repo: "alice/notes", text: "Tell from mcp" });
115 expect(mcp.error).toBeUndefined();
116
117 const told = ["composer", "pill", "share", "mcp"].map((p) => opened(`Tell from ${p}`));
118 expect(issues()).toHaveLength(before + 4);
119 const [first] = told;
120 for (const i of told) {
121 expect(i.kind).toBe(first!.kind);
122 expect(i.state).toBe("open");
123 expect(i.authorName).toBe("alice");
124 expect(i.assigneeName).toBe("bot");
125 expect(i.assigneeIsAgent).toBe(true);
126 expect(labelsFor(app.db, i.id).map((l) => l.name)).toEqual(
127 labelsFor(app.db, first!.id).map((l) => l.name),
128 );
129 }
130 expect(new Set(told.map((i) => i.number)).size).toBe(4);
131 });
132 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs