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

Feature flags, part 3: each request's variant is counted, and Settings → Flags shows the A/B read-out for 7 and 30 days with one button to make the winner the default #2159

Merged opened this

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

Merged

scout/forge:scout/1829-feature-flags-part-3-each-request-s-vari → main 5 commits fork 7 threads · 7 unresolved

  • Merged. scout/1829-feature-flags-part-3-each-request-s-vari was merged into main as 69b6b3e.

Review

Sign in to review this change.
10 files changed +294 −10
README.md +2
@@ -1167,3 +1167,5 @@ stays empty — the preview has no install step, so the files are committed unde
11671167
11681168 Not done: the SVG route is unchanged and still the card's source of truth; the
11691169 rasterizer runs on the main thread (about 100 ms once per distinct card).
1170
1171 Every read of a flag is counted in memory by UTC day and variant (on or off) and written in one batch every ten seconds and at exit, so a request runs no extra query. Beside each flag, Settings → Flags shows its reads per variant for the last 7 and 30 days. When one variant leads, "Make on/off the default" sets the flag to that value for everyone (user, role and percentage rules are cleared), and opens an issue on the forge's repository asking the crew to remove the losing path and the flag; if the issue cannot be opened, the flag is left as it was. The button shows only when the 30-day leader has at least 50 reads in all, the variants are not tied, and the flag's forge value is not already the leader. In the terminal the Flags screen shows the same `Uses, 7 days / 30 days` line for the flag under the cursor, and `w` does the same as the button (same admin, non-agent gate). Counts are of reads, not of people.

This paragraph sits at the end of the card-rasterizer section. Give it its own heading, for example "Flag read-out", so the next reader can find it.

src/actions/flag_winner.ts added +43
@@ -0,0 +1,43 @@
1 /**
2 * Make a flag's winner the default (#1829): the flag reads the winner at once
3 * for everyone (per-user, role and percentage rules cleared), and an issue on
4 * the forge's repository asks the crew to remove the losing path and the flag.
5 * Only the variant the read-out offers (`winnerOffer`: enough reads, no tie,
6 * not already the value) is accepted, whichever door asks. The issue is opened first, so a refusal leaves the flag as it was.
7 */
8
9 import type { App } from "../app.ts";
10 import { flagRegistry, mayManageFlags, winnerOffer } from "../core/flags.ts";
11 import type { Issue } from "../core/issues.ts";
12 import { getRepo } from "../core/repos.ts";
13 import type { User } from "../core/users.ts";
14 import { openIssue } from "./issues.ts";
15
16 export function makeWinnerDefault(
17 app: App,
18 actor: User,
19 name: string,
20 winner: "on" | "off",
21 ): { ok: true; issue: Issue } | { ok: false; message: string } {
outdated

Gate on the offer here, not just on admin: compute winnerOffer(registry.uses(app.db,name,30), registry.value(name)) and return {ok:false,message:"That variant is not the winner yet."} unless it equals winner. Both doors then share the rule, and a hand-crafted POST cannot rewrite a flag with no evidence.

22 if (!mayManageFlags(actor)) return { ok: false, message: "Flags are set by a forge admin." };
23 const registry = flagRegistry();
24 const decl = (registry.decls as Record<string, { why: string; issue: number }>)[name];
25 if (!Object.hasOwn(registry.decls, name) || decl === undefined)
26 return { ok: false, message: "There is no such flag." };

The decl === undefined check is redundant after Object.hasOwn, and the as never casts hide the typing. Narrow once and drop one of the two.

27 if (winnerOffer(registry.uses(app.db, name, 30), registry.value(name as never)) !== winner)
28 return { ok: false, message: "That variant is not the winner yet." };
29 const [owner, repoName] = (app.config.forgeRepo ?? "").split("/");
30 const repo = owner && repoName ? getRepo(app.db, owner, repoName) : undefined;
31 if (!repo) return { ok: false, message: "The forge names no repository to open the issue on." };
32 const loser = winner === "on" ? "off" : "on";
33 const issue = openIssue(app, actor, repo, {
34 title: `Remove flag ${name}: keep the ${winner} path`,
35 body:
36 `The A/B read-out for \`${name}\` ended with **${winner}** the winner, and it is now the flag's value for everyone.\n\n` +
37 `Remove the ${loser} path and the \`${name}\` flag (declared in \`src/core/flags.ts\`; owner #${decl.issue}). ` +
outdated

Good ordering (issue first). The TUI needs to call this too so both doors can do it.

38 `Flag: ${decl.why}`,
39 });
40 if (!issue.ok) return { ok: false, message: "The issue could not be opened." };
41 registry.set(app.db, name, { forge: winner === "on" }, actor.id);
42 return { ok: true, issue: issue.value };
43 }
src/core/flags.ts +61 −1
@@ -41,6 +41,24 @@ export type FlagValue = {
4141 percent?: number;
4242 };
4343
44 /** How many times a flag answered each way. */
45 export type FlagUses = { on: number; off: number };
46
47 /** Fewer reads than this in 30 days are too few to call a winner. */
48 export const MIN_WINNER_READS = 50;
49
50 /** The variant to offer as the default: the 30-day leader, unless it is too few reads, a tie, or already the forge's value. */
51 export function winnerOffer(uses: FlagUses, current: FlagValue | undefined): "on" | "off" | undefined {
52 if (uses.on + uses.off < MIN_WINNER_READS || uses.on === uses.off) return undefined;
53 const lead = uses.on > uses.off ? "on" : "off";
54 return current?.forge === (lead === "on") ? undefined : lead;
55 }
56
57 /** The read-out line both doors show. */
58 export function usesLine(week: FlagUses, month: FlagUses): string {
59 return `Uses, 7 days: on ${week.on} · off ${week.off} · 30 days: on ${month.on} · off ${month.off}`;
60 }
61
4462 const KEY_PREFIX = "flag.";
4563
4664 /** FNV-1a with a final avalanche, so near ids do not land in near buckets. */
@@ -169,6 +187,9 @@ export function unreadFlags(
169187 /** A flag registry over `decls`; the forge's own is `flag`/`loadFlags` below. */
170188 export function makeFlags<const D extends Record<string, FlagDecl>>(decls: D) {
171189 let values = new Map<string, FlagValue>();
190 // Reads waiting to be written: `day\tflag\tvariant` → count. Counting is a
191 // map bump; `flush` writes the lot in one transaction, so a read costs no query.
192 let pending = new Map<string, number>();
172193 return {
173194 /** Read every stored value once. A failure leaves the defaults. */
174195 load(db: Database): void {
@@ -230,7 +251,43 @@ export function makeFlags<const D extends Record<string, FlagDecl>>(decls: D) {
230251 return true;
231252 },
232253 flag(name: keyof D & string, viewer?: Viewer): boolean {
233 return evaluate(name, decls[name]!.default, values.get(name), viewer);
254 const on = evaluate(name, decls[name]!.default, values.get(name), viewer);
255 const key = `${new Date().toISOString().slice(0, 10)}\t${name}\t${on ? 1 : 0}`;
256 pending.set(key, (pending.get(key) ?? 0) + 1);
257 return on;
258 },
259 /** Write the counted reads. A failure keeps them for the next try. */
260 flush(db: Database): void {
261 if (pending.size === 0) return;
262 const taken = pending;
263 pending = new Map();
264 try {
265 const up = db.query(
266 `INSERT INTO flag_use (day, flag, variant, n) VALUES (?1, ?2, ?3, ?4)
267 ON CONFLICT (day, flag, variant) DO UPDATE SET n = n + excluded.n`,
268 );
269 db.transaction(() => {
270 for (const [key, n] of taken) {
271 const [day, flag, variant] = key.split("\t");
272 up.run(day!, flag!, Number(variant), n);
273 }
274 })();
275 } catch {
276 for (const [key, n] of taken) pending.set(key, (pending.get(key) ?? 0) + n);
277 }
278 },
279 /** Reads per variant over the last `days` UTC days, today included. */
280 uses(db: Database, name: string, days: number, now = new Date()): FlagUses {
281 this.flush(db);
282 const since = new Date(now.getTime() - (days - 1) * 86_400_000).toISOString().slice(0, 10);
283 const rows = db
284 .query<{ variant: number; n: number }, [string, string]>(
285 "SELECT variant, SUM(n) AS n FROM flag_use WHERE flag = ?1 AND day >= ?2 GROUP BY variant",
286 )
287 .all(name, since);
288 const out: FlagUses = { on: 0, off: 0 };
289 for (const r of rows) out[r.variant === 1 ? "on" : "off"] = r.n;
290 return out;
234291 },
235292 };
236293 }
@@ -243,6 +300,9 @@ export const loadFlags = forgeFlags.load;
243300 /** Whether `name` is on for `viewer` (none: signed out). */
244301 export const flag = forgeFlags.flag;
245302
303 /** Write the counted flag reads; the boot calls it on a timer and at exit. */
304 export const flushFlagUses = forgeFlags.flush;
305
246306 /** The forge's registry, for the settings page. */
247307 export const forgeRegistry = forgeFlags;
248308
src/core/migrations/20261016140000-flag-use.ts added +9
@@ -0,0 +1,9 @@
1 // Flag reads counted by the day (#1829): one row per UTC day, flag and the
2 // variant it answered (1 on, 0 off). Written in batches from memory.
3 export default `
4 CREATE TABLE flag_use (
5 day TEXT NOT NULL, flag TEXT NOT NULL, variant INTEGER NOT NULL,
6 n INTEGER NOT NULL DEFAULT 0,
7 PRIMARY KEY (day, flag, variant)
8 ) WITHOUT ROWID;
9 `;
src/main.ts +4 −1
@@ -11,7 +11,7 @@ import { parentUrlProblem } from "./core/parent_releases.ts";
1111 import { releaseEnv } from "./release/env.ts";
1212 import { createApp } from "./app.ts";
1313 import { backfillCarried } from "./core/carried.ts";
14 import { loadFlags } from "./core/flags.ts";
14 import { flushFlagUses, loadFlags } from "./core/flags.ts";
1515 import { reconcile } from "./core/reconcile.ts";
1616 import { syncAllPillars } from "./actions/pillars.ts";
1717 import { bootHostedChildren } from "./spawn/host.ts";
@@ -241,6 +241,7 @@ const app = createApp({
241241 });
242242
243243 loadFlags(app.db);
244 const flagTimer = setInterval(() => flushFlagUses(app.db), 10_000);
244245 await reconcile(app);
245246 // Issues linked under a pillar's umbrella before pillars had labels.
246247 const labelled = syncAllPillars(app);
@@ -565,6 +566,8 @@ function close(): void {
565566 app.log.info("forge shutting down");
566567 ssh?.stop();
567568 servers.stop();
569 clearInterval(flagTimer);
570 flushFlagUses(app.db);
568571 app.close();
569572 process.exit(0);
570573 }
src/tui/screens/flags.ts +27 −4
@@ -3,8 +3,9 @@
33 * why it exists, its default, the owner issue and its live value, as
44 * Settings → Flags shows them. Enter sets one from a line like
55 * `forge=on users=u1,u2 roles=admin percent=30` (checked by `checkInput`, the
6 * page's own check); `x` clears it. A change goes through the registry's
7 * `set`, so it is live at once, as on the page. The gate is the page's:
6 * page's own check); `x` clears it; `w` makes the 30-day winner the default
7 * and opens the removal issue (`makeWinnerDefault`). A change goes through
8 * the registry's `set`, so it is live at once, as on the page. The gate is the page's:
89 * an admin who is not an agent; anyone else sees nothing and sets nothing.
910 */
1011
@@ -12,10 +13,13 @@ import {
1213 checkInput,
1314 flagRegistry,
1415 mayManageFlags,
16 usesLine,
17 winnerOffer,
1518 type FlagDecl,
1619 type FlagInput,
1720 type FlagValue,
1821 } from "../../core/flags.ts";
22 import { makeWinnerDefault } from "../../actions/flag_winner.ts";
1923 import type { Ctx, Screen } from "../app/app.ts";
2024 import { binding } from "../app/keys.ts";
2125 import { style } from "../term/style.ts";
@@ -65,13 +69,30 @@ export function flagsScreen(f: Session, ctx: Ctx): Screen {
6569 ctx.invalidate();
6670 };
6771
72 /** The read-out of the flag under the cursor. */
73 const usesOf = (name: string): string =>
74 `${name} ${usesLine(flagRegistry().uses(f.db, name, 7), flagRegistry().uses(f.db, name, 30))}`;
75
76 const offer = (name: string) =>
77 winnerOffer(flagRegistry().uses(f.db, name, 30), flagRegistry().value(name as never));
78
79 const winner = (name: string): void => {
80 const lead = offer(name);
81 if (!allowed || f.user === undefined || lead === undefined) return;
82 const made = makeWinnerDefault(f.app, f.user, name, lead);
83 note = made.ok
84 ? `${name}: ${lead} is the default; issue #${made.issue.number} asks the crew to remove the flag.`
85 : made.message;
86 ctx.invalidate();
87 };
88
6889 const rows = (): ListRow[] =>
6990 names().map((name) => {
7091 const decl = (flagRegistry().decls as Record<string, FlagDecl>)[name]!;
7192 return {
7293 title: name,
7394 secondary: show(flagRegistry().value(name as never)),
74 meta: `${decl.why} default ${decl.default ? "on" : "off"} owner #${decl.issue}`,
95 meta: `${offer(name) === undefined ? "" : `w: make ${offer(name)} the default `}${decl.why} default ${decl.default ? "on" : "off"} owner #${decl.issue}`,

rows() runs offer(name) twice per flag, and each call flushes and runs a 30-day query. rows() runs on every render, so this is up to 2N queries per frame. Compute the offer once per row, and cache it for a few seconds.

7596 };
7697 });
7798
@@ -81,6 +102,7 @@ export function flagsScreen(f: Session, ctx: Ctx): Screen {
81102 binding(["j", "k"], "move cursor"),
82103 binding(["enter"], "set"),
83104 binding(["x"], "clear"),
105 binding(["w"], "make the winner the default"),
84106 ],
85107 render(area, s) {
86108 const c = ctx.theme.c;
@@ -92,7 +114,7 @@ export function flagsScreen(f: Session, ctx: Ctx): Screen {
92114 ? "Flags are set by a forge admin."
93115 : names().length === 0
94116 ? "No flag is declared yet."
95 : `${names().length} flag${names().length === 1 ? "" : "s"}`;
117 : usesOf(names()[state.index] ?? names()[0]!);
96118 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
97119 drawList(s, list!, c, state, rows());
98120 },
@@ -103,6 +125,7 @@ export function flagsScreen(f: Session, ctx: Ctx): Screen {
103125 const name = all[state.index];
104126 if (name === undefined) return false;
105127 if (e.key === "x") save(name, undefined);
128 else if (e.key === "w") winner(name);
106129 else if (e.key === "enter")
107130 ctx.push(
108131 promptScreen(ctx, screen, {
src/web/pages/flags.ts +21 −3
@@ -6,7 +6,8 @@
66 * Anyone else is told 403, and an agent is never an admin here.
77 */
88
9 import { checkInput, flagRegistry, mayManageFlags, useFlagRegistry, type FlagDecl, type FlagValue } from "../../core/flags.ts";
9 import { checkInput, flagRegistry, usesLine, winnerOffer, mayManageFlags, useFlagRegistry, type FlagDecl, type FlagValue } from "../../core/flags.ts";
10 import { makeWinnerDefault } from "../../actions/flag_winner.ts";
1011 import type { Ctx } from "../../http/context.ts";
1112 import { settingsLayout, signInFirst } from "../chrome.ts";
1213 import { field, readForm } from "../form.ts";
@@ -66,14 +67,17 @@ export function flagsPage(ctx: Ctx): Response {
6667 const rows = names.map((name) => {
6768 const decl = (registry.decls as Record<string, FlagDecl>)[name]!;
6869 const v = registry.value(name as never);
70 const week = registry.uses(ctx.app.db, name, 7);
71 const month = registry.uses(ctx.app.db, name, 30);
72 const lead = winnerOffer(month, v);
6973 return row({
7074 lead: icon("activity"),
7175 title: html`<span class="mono">${name}</span>`,
7276 meta: html`${decl.why}<br />Default
7377 <span class="mono">${decl.default ? "on" : "off"}</span> · owner
7478 ${issueLink(repo, decl.issue)} ·
75 live: ${summary(v)}`,
76 trail: html`<form class="row-actions" method="post" action="/settings/flags">
79 live: ${summary(v)}<br /><span class="mono">${usesLine(week, month)}</span>`,
80 trail: html`<form class="row-actions flag-form" method="post" action="/settings/flags">
7781 <input type="hidden" name="name" value="${name}" />
7882 <select class="input" name="forge" aria-label="${name} for the forge">
7983 <option value="" ${v?.forge === undefined ? "selected" : ""}>forge: default</option>
@@ -85,6 +89,9 @@ export function flagsPage(ctx: Ctx): Response {
8589 <input class="input mono" name="percent" placeholder="%" size="3" value="${v?.percent === undefined ? "" : String(v.percent)}" aria-label="${name} percent" />
8690 ${btn("Save", { size: "sm", variant: "primary", label: `Save ${name}` })}
8791 ${btn("Clear", { size: "sm", name: "clear", value: "1", label: `Clear ${name}` })}
92 ${lead === undefined
93 ? ""
94 : btn(`Make ${lead} the default`, { size: "sm", name: "winner", value: lead, label: `Make ${lead} the default for ${name}` })}
8895 </form>`,
outdated

Show this only when lead differs from the flag's current forge value, otherwise the click opens a pointless issue. Also add the matching key in src/tui/screens/flags.ts.

8996 });
9097 });
@@ -119,6 +126,17 @@ export async function flagsSet(ctx: Ctx): Promise<Response> {
119126 const form = await readForm(ctx, "admin");
120127 if (form instanceof Response) return form;
121128 const name = field(form, "name");
129 const winner = field(form, "winner");
130 if (winner === "on" || winner === "off") {
131 const made = makeWinnerDefault(ctx.app, ctx.auth.user!, name, winner);
132 return flashTo(
133 ctx,
134 "/settings/flags",
135 made.ok
136 ? { message: `${name}: ${winner} is the default; issue #${made.issue.number} asks the crew to remove the flag.` }
137 : { tone: "danger", message: made.message },
138 );
139 }
122140 const checked =
123141 field(form, "clear") === "1"
124142 ? ({ ok: true, value: undefined } as const)
src/web/style/content.ts +8
@@ -362,6 +362,14 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
362362 .row:has(.headroom) { flex-wrap: wrap; }
363363 .row:has(.headroom) .row-trail { flex-basis: 100%; flex-wrap: wrap; }
364364 }
365 /* Settings → Flags: a flag's controls wrap under its text on a phone, never sideways. */
366 .flag-form { flex-wrap: wrap; justify-content: flex-end; max-width: 100%; }
367 @media (max-width: 640px) {
368 .row:has(.flag-form) { flex-wrap: wrap; }
369 .row:has(.flag-form) .row-trail { flex-basis: 100%; min-width: 0; }
370 .flag-form { justify-content: flex-start; }
371 .flag-form .input { min-width: 0; flex: 1 1 8rem; }
372 }
365373 .backups-now { margin-top: 12px; }
366374 /* The fleet graph: a selection's spend and runs, hour by hour and day by day.
367375 Series colours are Okabe–Ito (told apart with common colour-vision
test/flags-settings.test.ts +96 −1
@@ -1,11 +1,13 @@
11 /** Settings → Flags (#1970): admin-only, validated, live at once, kept. */
22
3 import { Database } from "bun:sqlite";
34 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
45 import { mkdtemp, rm } from "node:fs/promises";
56 import { tmpdir } from "node:os";
67 import { join } from "node:path";
78 import { createApp, type App } from "../src/app.ts";
8 import { checkInput, makeFlags } from "../src/core/flags.ts";
9 import { checkInput, makeFlags, winnerOffer } from "../src/core/flags.ts";
10 import { createRepo } from "../src/core/repos.ts";
911 import { createUser } from "../src/core/users.ts";
1012 import { serve, type Servers } from "../src/http/server.ts";
1113 import { useFlagRegistry } from "../src/web/pages/flags.ts";
@@ -106,3 +108,96 @@ describe("checkInput", () => {
106108 expect(checkInput({ ...base, ...bad }).ok).toBe(false);
107109 });
108110 });
111
112 describe("flag read-out (#1829)", () => {
113 test("counts per variant match the table, for 7 and 30 days", () => {
114 const r = makeFlags({ alpha: decl });
115 const db = new Database(":memory:");
116 db.exec("CREATE TABLE flag_use (day TEXT NOT NULL, flag TEXT NOT NULL, variant INTEGER NOT NULL, n INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, flag, variant)) WITHOUT ROWID");
117 const day = (ago: number) => new Date(Date.now() - ago * 86_400_000).toISOString().slice(0, 10);
118 const put = db.query("INSERT INTO flag_use VALUES (?1, 'alpha', ?2, ?3)");
119 put.run(day(10), 1, 100);
120 put.run(day(40), 0, 900);
121 put.run(day(2), 0, 5);
122 expect(r.uses(db, "alpha", 7)).toEqual({ on: 0, off: 5 });
123 for (let i = 0; i < 3; i++) r.flag("alpha");
124 expect(r.uses(db, "alpha", 7)).toEqual({ on: 0, off: 8 });
125 expect(r.uses(db, "alpha", 30)).toEqual({ on: 100, off: 8 });
126 expect(db.query("SELECT SUM(n) AS n FROM flag_use WHERE flag = 'alpha' AND day >= ?1").get(day(29))).toEqual({ n: 108 });
127 expect(r.uses(db, "beta", 30)).toEqual({ on: 0, off: 0 });
128 });
129
130 test("a read runs no query; flush writes the batch", () => {
131 const r = makeFlags({ alpha: decl });
132 const db = new Database(":memory:"); // no table: a query would throw
133 for (let i = 0; i < 4; i++) expect(r.flag("alpha")).toBe(false);
134 r.flush(db); // fails quietly, keeps the counts
135 db.exec("CREATE TABLE flag_use (day TEXT NOT NULL, flag TEXT NOT NULL, variant INTEGER NOT NULL, n INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, flag, variant)) WITHOUT ROWID");
136 expect(r.uses(db, "alpha", 7)).toEqual({ on: 0, off: 4 });
137 });
138 });
139
140 describe("make the winner the default (#1829)", () => {
141 let dataDir: string;
142 let app: App;
143 let servers: Servers;
144 const reg2 = makeFlags({ beta: decl });
145 beforeAll(async () => {
146 dataDir = await mkdtemp(join(tmpdir(), "forge-flagwin-"));
147 app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge" });
148 servers = await serve(app);
149 const u = await createUser(app.db, { trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
150 if (!u.ok) throw new Error(u.error);
151 const made = await createRepo(app.db, app.reposRoot, { ownerId: u.value.id, name: "forge", visibility: "public" });
152 if (!made.ok) throw new Error(String(made.error));
153 useFlagRegistry(reg2);
154 });
155 afterAll(async () => {
156 useFlagRegistry(undefined);
157 servers.stop();
158 app.close();
159 await rm(dataDir, { recursive: true, force: true });
160 });
161
162 test("opens the removal issue and the flag reads the winner", async () => {
163 const res0 = await fetch(`${servers.url}/signin`, {
164 method: "POST", redirect: "manual",
165 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
166 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
167 });
168 const cookie = (res0.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
169 for (let i = 0; i < 60; i++) reg2.flag("beta"); // off x60: enough reads to call a winner
170 const post = (body: Record<string, string>) =>
171 fetch(`${servers.url}/settings/flags`, {
172 method: "POST", redirect: "manual",
173 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
174 body: new URLSearchParams(body),
175 });
176 reg2.set(app.db, "beta", { users: ["u1"] }, "x");
177 const page = await (await fetch(`${servers.url}/settings/flags`, { headers: { cookie } })).text();
178 expect(page).toContain("Uses, 7 days");
179 expect(page).toContain("Uses, 7 days: on 0 · off 60");
180 expect(page).toContain("Make off the default");
181 expect(winnerOffer({ on: 3, off: 2 }, undefined)).toBeUndefined(); // too few reads
182 expect(winnerOffer({ on: 5, off: 60 }, { forge: false })).toBeUndefined(); // already the value
183 expect(winnerOffer({ on: 30, off: 30 }, undefined)).toBeUndefined(); // a tie
184 expect(winnerOffer({ on: 5, off: 60 }, { forge: true })).toBe("off");
185
186 // The unoffered variant (and a flag with too few reads) is refused: no issue, no change.
187 expect((await post({ name: "beta", winner: "on" })).status).toBe(303);
188 expect(reg2.value("beta")).toEqual({ users: ["u1"] });
189 expect(app.db.query("SELECT 1 FROM issue").all()).toEqual([]);
190
191 expect((await post({ name: "beta", winner: "off" })).status).toBe(303);
192 expect(reg2.value("beta")).toEqual({ forge: false });
193 expect(reg2.flag("beta")).toBe(false);
outdated

This posts winner=on while the page offered off, and expects success. Post the offered variant, and add a refusal case for the other one.

194 const issues = app.db.query<{ title: string; body: string }, []>("SELECT title, body FROM issue").all();
195 expect(issues.length).toBe(1);
196 expect(issues[0]!.title).toContain("Remove flag beta");
197 expect(issues[0]!.body).toContain("#1970");
198 expect(issues[0]!.title).toContain("keep the off path");
199
200 await post({ name: "nope", winner: "on" });
201 expect(app.db.query("SELECT 1 FROM issue").all().length).toBe(1);
202 });
203 });
test/tui-flags.test.ts +23
@@ -9,6 +9,7 @@ import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { createApp, type App as Forge } from "../src/app.ts";
1111 import { makeFlags, useFlagRegistry } from "../src/core/flags.ts";
12 import { createRepo } from "../src/core/repos.ts";
1213 import { createUser, getUserByName } from "../src/core/users.ts";
1314 import { createForgeApp } from "../src/tui/forge.ts";
1415 import { flagsScreen, parseLine } from "../src/tui/screens/flags.ts";
@@ -100,6 +101,26 @@ describe("flags in the terminal", () => {
100101 h.stop();
101102 });
102103
104 test("the read-out shows, and w makes the winner the default and opens the issue", async () => {
105 const owner = getUserByName(forge.db, "alice")!;
106 const made = await createRepo(forge.db, forge.reposRoot, { ownerId: owner.id, name: "forge", visibility: "public" });
107 if (!made.ok) throw new Error(String(made.error));
108 forge.config.forgeRepo = "alice/forge";
109 for (let i = 0; i < 60; i++) reg.flag("alpha");
110 const h = boot("alice");
111 await open(h);
112 expect(h.text()).toContain("Uses, 7 days: on 1 · off 60");
113 expect(h.text()).toContain("w: make off the default");
114 await h.press("w");
115 expect(reg.value("alpha")).toEqual({ forge: false });
116 expect(h.text()).toContain("off is the default; issue #1");
117 expect(forge.db.query("SELECT title FROM issue").all()).toEqual([{ title: "Remove flag alpha: keep the off path" }]);
118 await h.press("w"); // already the value: no second issue
119 expect(forge.db.query("SELECT 1 FROM issue").all().length).toBe(1);
120 await h.press("x");
121 h.stop();
122 });
123
103124 test("a bad line is refused and nothing changes", async () => {
104125 const h = boot("alice");
105126 await open(h);
@@ -132,8 +153,10 @@ describe("flags in the terminal", () => {
132153 const key = (k: string) => screen.key?.({ type: "key", key: k, ctrl: false, alt: false, shift: false } as never);
133154 key("enter");
134155 key("x");
156 key("w");
135157 expect(pushed).toEqual([]);
136158 expect(reg.value("alpha")).toBeUndefined();
159 expect(forge.db.query("SELECT 1 FROM issue WHERE author_id = ?1").all(user.id)).toEqual([]);
137160 expect(forge.db.query("SELECT key FROM forge_setting_change WHERE key = 'flag.alpha' AND actor_id = ?1").all(user.id).length).toBe(0);
138161 }
139162 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs