|
|
@@ -0,0 +1,210 @@ |
| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 7 |
import { mkdtemp, rm } from "node:fs/promises"; |
| 8 |
import { tmpdir } from "node:os"; |
| 9 |
import { join } from "node:path"; |
| 10 |
import { createApp, type App } from "../src/app.ts"; |
| 11 |
import { enableApp } from "../src/actions/apps.ts"; |
| 12 |
import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 |
import { linkIssue } from "../src/actions/dependencies.ts"; |
| 14 |
import { openIssue } from "../src/actions/issues.ts"; |
| 15 |
import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 16 |
import { smallShare } from "../src/core/capacity.ts"; |
| 17 |
import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| 18 |
import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 19 |
import { createRun } from "../src/core/runs.ts"; |
| 20 |
import { createUser, type User } from "../src/core/users.ts"; |
| 21 |
import { driveRepo, smallShareWords } from "../src/crew/autonomy.ts"; |
| 22 |
import { isSmall, shareWords, slotOrder, smallCap, smallIssuesWords } from "../src/crew/small_share.ts"; |
| 23 |
import { readHeld } from "../src/flow/held.ts"; |
| 24 |
import { serve, type Servers } from "../src/http/server.ts"; |
| 25 |
import { heldLines } from "../src/tui/screens/flow.ts"; |
| 26 |
|
| 27 |
describe("the rules", () => { |
| 28 |
test("a test that pins something, or a tests/small label, is small; a feature is not", () => { |
| 29 |
expect(isSmall("A test pins the merge queue's batch size", [])).toBe(true); |
| 30 |
expect(isSmall("Tests: cover the people screen", [])).toBe(true); |
| 31 |
expect(isSmall("Add a terminal shortcut", ["small"])).toBe(true); |
| 32 |
expect(isSmall("Search notes by word", ["feature"])).toBe(false); |
| 33 |
}); |
| 34 |
|
| 35 |
test("5 slots at 30% hold 2 small; 1 slot still holds 1; 0% holds none", () => { |
| 36 |
expect(smallCap(5, 30)).toBe(2); |
| 37 |
expect(smallCap(1, 30)).toBe(1); |
| 38 |
expect(smallCap(5, 0)).toBe(0); |
| 39 |
expect(smallCap(5, 100)).toBe(5); |
| 40 |
}); |
| 41 |
|
| 42 |
test("roadmap first, small last, and small left out at the cap", () => { |
| 43 |
const c = (t: string) => ({ raised: false, roadmap: t.startsWith("r"), small: t.startsWith("s") }); |
| 44 |
expect(slotOrder(["s1", "o1", "r1", "s2", "r2"], c, 0, 2)).toEqual(["r1", "r2", "o1", "s1", "s2"]); |
| 45 |
expect(slotOrder(["s1", "o1", "r1"], c, 2, 2)).toEqual(["r1", "o1"]); |
| 46 |
}); |
| 47 |
|
| 48 |
test("what the owner raised again stays on top, in the map's order; a raised small one still keeps to the cap", () => { |
| 49 |
const c = (t: string) => ({ raised: t.endsWith("!"), roadmap: t.startsWith("r"), small: t.startsWith("s") }); |
| 50 |
expect(slotOrder(["r1", "o1!", "s1!", "r2"], c, 0, 2)).toEqual(["o1!", "s1!", "r1", "r2"]); |
| 51 |
expect(slotOrder(["r1", "o1!", "s1!"], c, 2, 2)).toEqual(["o1!", "r1"]); |
| 52 |
}); |
| 53 |
|
| 54 |
test("each label the docs name is small, and the words read as the doors print them", () => { |
| 55 |
for (const label of ["test", "tests", "tests-only", "small"]) expect(isSmall("Anything", [label])).toBe(true); |
| 56 |
expect(shareWords(1, 2)).toBe("1/2"); |
| 57 |
expect(smallIssuesWords({ running: 1, cap: 2 })).toBe("small issues: 1 of 2 slots"); |
| 58 |
}); |
| 59 |
}); |
| 60 |
|
| 61 |
let dataDir: string; |
| 62 |
let app: App; |
| 63 |
let alice: User; |
| 64 |
let repo: Repo; |
| 65 |
let servers: Servers; |
| 66 |
let cookie: string; |
| 67 |
|
| 68 |
const get = (path: string) => |
| 69 |
fetch(`${servers.url}${path}`, { headers: { cookie: `${cookie}; forge_mode=studio` }, redirect: "manual" }); |
| 70 |
|
| 71 |
|
| 72 |
const setShare = (value: string) => |
| 73 |
fetch(`${servers.url}/settings/capacity`, { |
| 74 |
method: "POST", |
| 75 |
redirect: "manual", |
| 76 |
headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: servers.url }, |
| 77 |
body: new URLSearchParams({ key: "slots.smallShare", value }), |
| 78 |
}); |
| 79 |
|
| 80 |
const fresh = () => getRepoById(app.db, repo.id)!; |
| 81 |
|
| 82 |
|
| 83 |
const opened: Issue[] = []; |
| 84 |
|
| 85 |
function open(title: string): Issue { |
| 86 |
const made = openIssue(app, alice, fresh(), { title }); |
| 87 |
if (!made.ok) throw new Error(String(made.error)); |
| 88 |
opened.push(made.value); |
| 89 |
return made.value; |
| 90 |
} |
| 91 |
|
| 92 |
|
| 93 |
async function drive(issues: readonly Issue[]): Promise<Issue | undefined> { |
| 94 |
const before = new Set(issues.filter((i) => getIssueById(app.db, i.id)!.assigneeId !== null).map((i) => i.id)); |
| 95 |
if ((await driveRepo(app, fresh())) !== "picked") return undefined; |
| 96 |
const picked = issues.map((i) => getIssueById(app.db, i.id)!).find((i) => i.assigneeId !== null && !before.has(i.id)); |
| 97 |
if (picked === undefined) return undefined; |
| 98 |
createRun(app.db, { repoId: repo.id, kind: "agent", issueId: picked.id, actorId: picked.assigneeId ?? undefined, reason: "assigned" }); |
| 99 |
return picked; |
| 100 |
} |
| 101 |
|
| 102 |
beforeAll(async () => { |
| 103 |
dataDir = await mkdtemp(join(tmpdir(), "forge-small-share-")); |
| 104 |
app = createApp({ dataDir, port: 0 }); |
| 105 |
const a = await createUser(app.db, { |
| 106 |
origin: "seed", trusted: true, username: "alice", email: "[email protected]", |
| 107 |
password: "a-good-passphrase", adminIfFirst: true, |
| 108 |
}); |
| 109 |
if (!a.ok) throw new Error(a.error); |
| 110 |
alice = a.value; |
| 111 |
const made = await createRepo(app, alice, { name: "notes", visibility: "private" }); |
| 112 |
if (!made.ok) throw new Error(String(made.error)); |
| 113 |
repo = made.value; |
| 114 |
const first = await commitToBranch(app, alice, repo, { |
| 115 |
message: "Start", |
| 116 |
files: [ |
| 117 |
{ path: "package.json", content: `{"name":"notes","scripts":{"start":"bun run server.ts"}}\n` }, |
| 118 |
{ path: "server.ts", content: "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n" }, |
| 119 |
], |
| 120 |
}); |
| 121 |
if (!first.ok) throw new Error(JSON.stringify(first.error)); |
| 122 |
|
| 123 |
const enabled = await enableApp(app, alice, repo, { slug: "notes" }); |
| 124 |
if (!enabled.ok) throw new Error(String(enabled.error)); |
| 125 |
for (const name of ["b1", "b2", "b3", "b4", "b5"]) { |
| 126 |
const agent = await createAgent(app, alice, { username: name }); |
| 127 |
if (!agent.ok) throw new Error(String(agent.error)); |
| 128 |
expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 129 |
} |
| 130 |
const on = await updateRepoSettings(app, alice, repo, { |
| 131 |
autonomy: "autonomous", |
| 132 |
autonomyDirection: "Keep the notes tidy.", |
| 133 |
autonomyCeilingUsd: 10, |
| 134 |
autonomySlots: 5, |
| 135 |
autonomyBy: alice.id, |
| 136 |
}); |
| 137 |
expect(on.ok).toBe(true); |
| 138 |
servers = await serve(app); |
| 139 |
const signin = await fetch(`${servers.url}/signin`, { |
| 140 |
method: "POST", |
| 141 |
redirect: "manual", |
| 142 |
headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, |
| 143 |
body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), |
| 144 |
}); |
| 145 |
cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 146 |
}); |
| 147 |
|
| 148 |
afterAll(async () => { |
| 149 |
servers.stop(); |
| 150 |
app.close(); |
| 151 |
await rm(dataDir, { recursive: true, force: true }); |
| 152 |
}); |
| 153 |
|
| 154 |
test("with 5 slots and only small issues ready, at most 2 start; then an epic's issue starts before a small one", async () => { |
| 155 |
expect(smallShare(app.db)).toBe(30); |
| 156 |
const small = ["A test pins the title", "A test pins the body", "A test pins the date", "A test pins the tags"].map(open); |
| 157 |
const started: Issue[] = []; |
| 158 |
for (let n = 0; n < 5; n++) { |
| 159 |
const picked = await drive(small); |
| 160 |
if (picked !== undefined) started.push(picked); |
| 161 |
} |
| 162 |
expect(started.length).toBe(2); |
| 163 |
expect(smallShareWords(app, fresh())).toBe("2/2"); |
| 164 |
|
| 165 |
|
| 166 |
const epic = open("Notes search"); |
| 167 |
const child = open("Search notes by word"); |
| 168 |
expect(linkIssue(app, alice, fresh(), epic, child).ok).toBe(true); |
| 169 |
app.db.query("UPDATE run SET state = 'ok' WHERE issue_id = ?1").run(started[0]!.id); |
| 170 |
const next = await drive([...small, child]); |
| 171 |
expect(next?.id).toBe(child.id); |
| 172 |
}); |
| 173 |
|
| 174 |
test("one small issue of two slots shows on /board's Today, the studio's crew panel and the terminal's Flow", async () => { |
| 175 |
expect(smallShareWords(app, fresh())).toBe("1/2"); |
| 176 |
const board = await (await get("/board")).text(); |
| 177 |
const today = board.slice(board.indexOf('id="today"')); |
| 178 |
expect(today).toContain("small issues: 1 of 2 slots"); |
| 179 |
const studio = await (await get("/alice/notes/settings")).text(); |
| 180 |
expect(studio).toMatch(/<dt>Small issues<\/dt>\s*<dd>1\/2 slots<\/dd>/); |
| 181 |
const flow = heldLines(readHeld(app, true), 200).join("\n"); |
| 182 |
expect(flow).toMatch(/^alice\/notes startable=\d+.* small 1\/2$/m); |
| 183 |
}); |
| 184 |
|
| 185 |
test("what the owner raised again starts before an epic's issue", async () => { |
| 186 |
const plain = open("Rename a note"); |
| 187 |
const epic = open("Notes export"); |
| 188 |
const child = open("Export notes as text"); |
| 189 |
expect(linkIssue(app, alice, fresh(), epic, child).ok).toBe(true); |
| 190 |
app.db.query("INSERT INTO issue_raise (issue_id, raised_at) VALUES (?1, ?2)").run(plain.id, new Date().toISOString()); |
| 191 |
expect((await drive([plain, child]))?.id).toBe(plain.id); |
| 192 |
expect((await drive([plain, child]))?.id).toBe(child.id); |
| 193 |
}); |
| 194 |
|
| 195 |
test("slots.smallShare set on /settings/capacity changes the pick: 0 starts no small issue, 100 does; out of range is refused", async () => { |
| 196 |
const small = open("A test pins the colour"); |
| 197 |
expect((await setShare("0")).status).toBe(303); |
| 198 |
expect(smallShare(app.db)).toBe(0); |
| 199 |
expect(await drive(opened)).toBeUndefined(); |
| 200 |
expect(getIssueById(app.db, small.id)!.assigneeId).toBeNull(); |
| 201 |
expect((await setShare("100")).status).toBe(303); |
| 202 |
expect(smallShare(app.db)).toBe(100); |
| 203 |
const picked = await drive(opened); |
| 204 |
expect(picked).toBeDefined(); |
| 205 |
expect(isSmall(picked!.title, [])).toBe(true); |
| 206 |
for (const value of ["101", "-1", "half"]) { |
| 207 |
await setShare(value); |
| 208 |
expect(smallShare(app.db)).toBe(100); |
| 209 |
} |
| 210 |
}); |