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

Absorb: the menu entry's name and page follow the app's latest production commit, not the admin's act #9642

Merged opened this

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

Merged

mason/forge:mason/9541-absorb-the-menu-entry-s-name-and-page-fo → main 6 commits fork 2 threads · 2 unresolved

  • Merged. mason/9541-absorb-the-menu-entry-s-name-and-page-fo was merged into main as 55e703c.

Review

Sign in to review this change.
17 files changed +315 −25
docs/features/absorb.md +14
@@ -83,3 +83,17 @@ Absorb was built by Ben (benjivers). The review of #9441 found:
8383 - **Speed:** the Features list reads git for every app on every request (#9543).
8484
8585 This section is ready for the owner to share with Ben.
86
87 ## The menu entry is the admin's, not the deploy's
88
89 The name and page in the menu are the ones the admin saw when absorbing. If the production
90 `forge.json` later declares others, the menu keeps serving the absorbed ones and Settings ›
91 Features shows "declaration changed: review" with the new values; an admin accepts them
92 (`POST /settings/features/:slug/accept` with the reviewed `name` and `page`; refused with a re-review flash when the app declares something else by now; event `app.feature_declared`). Feature entries sit under
93 their own "Features from apps" heading, and a name equal to a built-in menu label is refused.
94
95 ### Accepting in the terminal
96
97 Open the palette and choose "Menu entries to review" (site admins who are people only). It lists the
98 absorbed apps whose declaration changed; `j`/`k` move, `a` accepts the selected row. The accept posts the
99 name and page the screen showed, so one that changed since is refused ("review it again"), as on the page.
src/actions/features.ts +44 −3
@@ -24,6 +24,7 @@ import { appsRuntime } from "../apps/runtime.ts";
2424 import { getAppById, getAppBySlug, listApps, type AppRecord } from "../core/apps.ts";
2525 import {
2626 absorb,
27 keepDeclaration,
2728 featureSettings,
2829 getAbsorbed,
2930 listAbsorbed,
@@ -46,7 +47,9 @@ export type FeatureError =
4647 /** The app is not absorbed: its settings are not the forge's to change. */
4748 | "not_absorbed"
4849 /** A value that does not fit its declaration; `message` names it. */
49 | "invalid_setting";
50 | "invalid_setting"
51 /** What the admin reviewed is not what the app declares now: review again. */
52 | "declaration_changed";
5053
5154 export interface SettingView {
5255 readonly key: string;
@@ -69,6 +72,8 @@ export interface FeatureView {
6972 readonly name: string;
7073 readonly summary: string | null;
7174 readonly page: string;
75 /** The name and page the app declares now, when they differ from the absorbed ones: waits for an admin. */
76 readonly pending: { readonly name: string; readonly page: string } | null;
7277 readonly absorbed: boolean;
7378 readonly absorbedBy: string | null;
7479 readonly absorbedAt: string | null;
@@ -121,9 +126,13 @@ function viewOf(record: AppRecord, repo: Repo, app: App, spec: FeatureSpec | und
121126 return {
122127 slug: record.slug,
123128 repo: `${repo.ownerName}/${repo.name}`,
124 name: spec?.name ?? record.slug,
129 name: absorbed?.name ?? spec?.name ?? record.slug,
125130 summary: spec?.summary ?? null,
126 page: spec?.page ?? "/",
131 page: absorbed?.page ?? spec?.page ?? "/",
132 pending:
133 absorbed !== undefined && spec !== undefined && (spec.name !== absorbed.name || spec.page !== absorbed.page)
134 ? { name: spec.name, page: spec.page }
135 : null,
127136 absorbed: absorbed !== undefined,
128137 absorbedBy: absorbed?.absorbedBy ?? null,
129138 absorbedAt: absorbed?.absorbedAt ?? null,
@@ -227,6 +236,38 @@ export async function absorbApp(app: App, actor: User | undefined, slug: string)
227236 return ok(viewOf(found.record, found.repo, app, declared.value, null));
228237 }
229238
239 /**
240 * Accept the name and page the admin reviewed as the menu's. Refused with
241 * `declaration_changed` when the app declares something else by now, so an
242 * admin never accepts values they did not see. An admin's act, and an event.
243 */
244 export async function acceptDeclaration(
245 app: App,
246 actor: User | undefined,
247 slug: string,
248 reviewed: { readonly name: string; readonly page: string },
249 ): Promise<Result<FeatureView, FeatureError>> {
250 const found = readable(app, actor, slug);
251 if (found === undefined) return err("not_found");
252 if (!siteAdmin(actor)) return err("forbidden");
253 const before = getAbsorbed(app.db, found.record.id);
254 if (before === undefined) return err("not_absorbed");
255 const declared = await declaredFeature(app, found.repo);
256 if (!declared.ok || declared.value === undefined) return err("not_a_feature");
257 const { name, page } = declared.value;
258 if (name !== reviewed.name || page !== reviewed.page) return err("declaration_changed");
259 if (name !== before.name || page !== before.page) {
260 keepDeclaration(app.db, found.record.id, name, page);
261 app.bus.append({
262 kind: "app.feature_declared",
263 repoId: found.repo.id,
264 actorId: actor.id,
265 payload: { slug, name, page, was: { name: before.name, page: before.page } },
266 });
267 }
268 return ok(viewOf(found.record, found.repo, app, declared.value, null));
269 }
270
230271 /** Put an absorbed app back as an ordinary app. Its settings are kept for the next absorb. */
231272 export async function unabsorbApp(app: App, actor: User | undefined, slug: string): Promise<Result<FeatureView, FeatureError>> {
232273 const found = readable(app, actor, slug);
src/apps/feature.ts +16
@@ -28,6 +28,21 @@ export type SettingType = "bool" | "int" | "string" | "choice" | "days" | "list"
2828
2929 export const SETTING_TYPES: readonly SettingType[] = ["bool", "int", "string", "choice", "days", "list"];
3030
31 /** The labels the forge's own menus use: a feature may not borrow one. */
32 const BUILT_IN_MENU_LABELS = [
33 "your profile", "repositories", "apps", "notes", "talk", "explore", "packs", "displays", "map", "features",
34 "new app", "new repository", "new issue", "new organization", "migrate", "settings", "access tokens", "ssh keys",
35 "origins", "boards", "crew", "system memory", "help", "sign out", "log out", "sign in", "admin", "administration",
36 "theme", "create", "new forge", "create new", "appearance and help", "features from apps", "keyboard shortcuts",
37 ];
38
39 const menuForm = (s: string): string => s.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
40
41 /** True when `name` reads as one of the forge's own menu labels. */
42 export function isBuiltInMenuLabel(name: string): boolean {
43 return BUILT_IN_MENU_LABELS.includes(menuForm(name));
44 }
45
3146 /** The days a `days` setting can name, in the order they are kept and shown. */
3247 export const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
3348
@@ -79,6 +94,7 @@ export function parseFeature(forgeJson: Raw): Result<FeatureSpec | undefined, st
7994 const name = block["name"];
8095 if (typeof name !== "string" || name.trim() === "" || name.length > NAME_MAX)
8196 return bad(`name is text of 1 to ${NAME_MAX} characters`);
97 if (isBuiltInMenuLabel(name)) return bad("name is already a label in the forge's own menu");
8298 const summary = block["summary"];
8399 if (summary !== undefined && (typeof summary !== "string" || summary.length > SUMMARY_MAX))
84100 return bad(`summary is text of at most ${SUMMARY_MAX} characters`);
src/apps/feature_env.ts +3 −5
@@ -8,13 +8,12 @@
88
99 import type { Database } from "bun:sqlite";
1010 import { featureEnv, parseFeature } from "./feature.ts";
11 import { featureSettings, getAbsorbed, refreshAbsorbed } from "../core/features.ts";
11 import { featureSettings, getAbsorbed } from "../core/features.ts";
1212
1313 export function featureVarsAt(
1414 db: Database,
1515 appId: string,
1616 forgeJson: string | undefined,

Dead parameter. Remove _o and the production option at the call sites.

17 o: { production?: boolean } = {},
1817 ): Record<string, string> {
1918 if (forgeJson === undefined || getAbsorbed(db, appId) === undefined) return {};
2019 let parsed: unknown;
@@ -26,9 +25,8 @@ export function featureVarsAt(
2625 if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
2726 const spec = parseFeature(parsed as Record<string, unknown>);
2827 if (!spec.ok || spec.value === undefined) return {};
29 // The menu names it as the code production runs names it; a preview's
30 // forge.json is a pull's, and does not speak for the forge's menu.
31 if (o.production === true) refreshAbsorbed(db, appId, spec.value.name, spec.value.page);
28 // The menu keeps the name and page the admin saw at absorb; a changed
29 // declaration waits for an admin to accept it (`acceptDeclaration`).
3230 return featureEnv(spec.value, featureSettings(db, appId));
3331 }
3432
src/apps/runtime.ts +1 −1
@@ -1695,7 +1695,7 @@ export class AppsRuntime {
16951695 } catch {
16961696 forgeJson = undefined;
16971697 }
1698 return featureVarsAt(this.app.db, appId, forgeJson, { production: env === "production" });
1698 return featureVarsAt(this.app.db, appId, forgeJson);
16991699 }
17001700
17011701 /**
src/core/features.ts +2 −2
@@ -36,8 +36,8 @@ export function absorb(db: Database, appId: string, by: string, name: string, pa
3636 );
3737 }
3838
39 /** The declaration's name and page as last read, for the nav. */
40 export function refreshAbsorbed(db: Database, appId: string, name: string, page: string): void {
39 /** Keep this name and page for the nav: an admin's act (accepting a changed declaration), never a deploy's. */
40 export function keepDeclaration(db: Database, appId: string, name: string, page: string): void {
4141 db.query("UPDATE app_feature SET name = ?2, page = ?3 WHERE app_id = ?1").run(appId, name, page);
4242 }
4343
src/design/activity.ts +4
@@ -64,6 +64,10 @@ export function eventVerb(event: ForgeEvent): string {
6464 const name = payload["name"];
6565 return `absorbed ${typeof name === "string" ? name : "the app"} as a forge feature`;
6666 }
67 if (kind === "app.feature_declared") {
68 const name = payload["name"];
69 return `accepted the feature's declared name ${typeof name === "string" ? name : "and page"}`;
70 }
6771 if (kind === "app.unabsorbed") return "put the app back as an ordinary app";
6872 if (kind === "app.feature_settings") {
6973 const keys = payload["keys"];
src/http/api/features.ts +2 −1
@@ -25,12 +25,13 @@ import type { Router } from "../router.ts";
2525 const WORDS: Record<Exclude<FeatureError, "not_found" | "forbidden" | "invalid_setting">, string> = {
2626 not_a_feature: "The app's forge.json declares no feature, or declares it wrongly.",
2727 not_absorbed: "The app is not absorbed.",
28 declaration_changed: "The app declares something else than was reviewed.",
2829 };
2930
3031 function refused(error: FeatureError, message?: string): Response {
3132 if (error === "not_found" || error === "forbidden") return respondAccess(error, 403);
3233 if (error === "invalid_setting") return json({ error, message: message ?? "A setting does not fit." }, 422);
33 return json({ error, message: WORDS[error] }, error === "not_absorbed" ? 409 : 422);
34 return json({ error, message: WORDS[error] }, error === "not_absorbed" || error === "declaration_changed" ? 409 : 422);
3435 }
3536
3637 export function featureRoutes(router: Router<Handler>): void {
src/http/settings_routes.ts +2 −1
@@ -31,7 +31,7 @@ import {
3131 signInAct,
3232 } from "../web/pages/accounts.ts";
3333 import { confirmsList, confirmYes } from "../web/pages/confirms.ts";
34 import { featurePage, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts";
34 import { featureAccept, featurePage, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts";
3535 import { passkeyAdd, passkeyDelete, passkeyOptions, passkeysPage } from "../web/pages/passkeys.ts";
3636 import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts";
3737 import {
@@ -187,6 +187,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
187187 router.add("POST", "/settings/features/:slug", featureSave);
188188 router.add("POST", "/settings/features/:slug/absorb", featureSwitch(true));
189189 router.add("POST", "/settings/features/:slug/unabsorb", featureSwitch(false));
190 router.add("POST", "/settings/features/:slug/accept", featureAccept);
190191 router.add("POST", "/settings/flags", flagsSet);
191192 router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns));
192193 router.add("GET", "/settings/spend", spendPage);
src/tui/screens/entries.ts +2
@@ -25,6 +25,7 @@ import { entry as displays } from "./displays.ts";
2525 import { entry as domains } from "./domains.ts";
2626 import { entry as edgeOnly } from "./edgeonly.ts";
2727 import { entry as traffic } from "./trafficview.ts";
28 import { entry as featureReview } from "./features.ts";
2829 import { entry as feed } from "./feed.ts";
2930 import { entry as forgeView } from "./forgeview.ts";
3031 import { entry as front } from "./front.ts";
@@ -111,6 +112,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
111112 connections,
112113 domains,
113114 feed,
115 featureReview,
114116 front,
115117 owner,
116118 forges,
src/tui/screens/features.ts added +82
@@ -0,0 +1,82 @@
1 /**
2 * Absorbed apps whose declared menu entry changed, in the terminal: the rows
3 * Settings › Features marks "declaration changed: review". `a` accepts the
4 * selected row's declaration through `acceptDeclaration`, the page's own
5 * action, posting what the screen showed, so a declaration that moved since is
6 * refused. Offered to a site admin who is a person, as the page is.
7 */
8
9 import { acceptDeclaration, listFeatures, type FeatureView } from "../../actions/features.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import { style } from "../term/style.ts";
13 import { truncate } from "../term/width.ts";
14 import type { Session } from "../session.ts";
15 import { fill, len, split } from "../ui/layout.ts";
16 import { emptyState } from "../ui/widgets.ts";
17 import { ListState, drawList, type ListRow } from "./common.ts";
18 import type { PaletteEntry } from "./entries.ts";
19
20 export interface FeaturesScreen extends Screen {
21 /** The read or accept in flight, for whoever must wait on it. */
22 busy(): Promise<void>;
23 }
24
25 const mayReview = (f: Session): boolean => f.user !== undefined && f.user.isAdmin && f.user.kind !== "agent";
26
27 export function featuresScreen(f: Session, ctx: Ctx): FeaturesScreen {
28 const state = new ListState();
29 let rows: FeatureView[] = [];
30 let work: Promise<void> = Promise.resolve();
31 const reload = (): Promise<void> =>
32 listFeatures(f.app, f.user).then((r) => {
33 rows = r.ok ? r.value.filter((v) => v.pending !== null) : [];
34 state.clamp(rows.length);
35 ctx.invalidate();
36 });
37 work = reload();
38
39 return {
40 title: "Menu entries to review",
41 keys: [binding(["j", "k"], "move cursor"), binding(["a"], "accept")],
42 render(area, s) {
43 const c = ctx.theme.c;
44 const [head, body] = split(area, "v", [len(1), fill(1)]);
45 if (rows.length === 0) {
46 emptyState(s, body!, c, { title: "No absorbed app has a declaration to review." });
47 return;
48 }
49 const line = mayReview(f) ? "a accepts the app's declaration as the menu entry" : "only a site admin accepts";
50 s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
51 const list: ListRow[] = rows.map((r) => ({
52 title: `${r.name} (${r.page})`,
53 secondary: `${r.repo} declares ${r.pending!.name} (${r.pending!.page})`,
54 meta: "",
55 }));
56 drawList(s, body!, c, state, list);
57 },
58 key(e) {
59 if (state.key(e, rows.length)) return true;
60 if (e.ctrl || e.alt || e.key !== "a") return false;
61 const row = rows[state.index];
62 if (row === undefined || row.pending === null || !mayReview(f)) return false;
63 work = acceptDeclaration(f.app, f.user, row.slug, row.pending).then(async (done) => {
64 if (done.ok) ctx.toast(`${row.pending!.name} is now the menu's entry`, "success");
65 else if (done.error === "declaration_changed") ctx.toast("the app declared something else; review it again", "danger");
66 else ctx.toast("that could not be accepted", "danger");
67 await reload();
68 });
69 return true;
70 },
71 busy: () => work,
72 };
73 }
74
75 /** The pending declarations: a site admin's, as the settings page is. */
76 export const entry: PaletteEntry = {
77 id: "feature-review",
78 title: "Menu entries to review",
79 hint: "absorbed apps whose declared name or page changed: accept them",
80 offered: mayReview,
81 run: (session, ctx) => ctx.push(featuresScreen(session, ctx)),
82 };
src/web/layout.ts +5 −1
@@ -271,7 +271,7 @@ function newMenu(ctx: Ctx): Html {
271271 */
272272 function featureItems(ctx: Ctx, user: User): MenuItem[] {
273273 const runtime = appsRuntime(ctx.app);
274 return absorbedForNav(ctx.app, user).map((f) => {
274 const entries = absorbedForNav(ctx.app, user).map((f) => {
275275 // Without a runtime there is no host to open: the app's own page instead.
276276 const href =
277277 runtime === undefined
@@ -279,6 +279,10 @@ function featureItems(ctx: Ctx, user: User): MenuItem[] {
279279 : `/apps/open?host=${encodeURIComponent(new URL(runtime.urlFor(f.record, "production")).host)}&to=${encodeURIComponent(f.page)}`;
280280 return { label: f.name, href, icon: "rocket" as const };
281281 });
282 // Under a heading of their own, so an app's chosen name cannot pass for a forge item.
283 return entries.length === 0
284 ? []
285 : [{ separator: true, label: "" }, { label: "Features from apps", heading: true }, ...entries];
282286 }
283287
284288 function userMenu(
src/web/pages/features.ts +36 −3
@@ -8,6 +8,7 @@
88
99 import {
1010 absorbApp,
11 acceptDeclaration,
1112 changeFeatureSettings,
1213 getFeature,
1314 listFeatures,
@@ -47,13 +48,21 @@ function featureRow(f: FeatureView) {
4748 return row({
4849 lead: icon("rocket"),
4950 title: html`${f.name} <span class="muted small mono">${f.repo}</span>
50 ${f.absorbed ? badge("absorbed", { tone: "success" }) : null}`,
51 meta: f.problem !== null
51 ${f.absorbed ? badge("absorbed", { tone: "success" }) : null}
52 ${f.pending !== null ? badge("declaration changed: review", { tone: "attn" }) : null}`,
53 meta: f.pending !== null
54 ? html`Menu: <strong>${f.name}</strong> at <span class="mono">${f.page}</span> → app: <strong>${f.pending.name}</strong> at
55 <span class="mono">${f.pending.page}</span>`
56 : f.problem !== null
5257 ? html`<span class="danger">${f.problem}</span>`
5358 : html`${f.summary ?? ""}${when(f.summary !== null, () => html`<br />`)}${f.settings.length} setting${f.settings.length === 1 ? "" : "s"}${f.absorbed
5459 ? html` · absorbed by ${f.absorbedBy ?? "someone"}`
5560 : html` · not absorbed: the app runs as an ordinary app`}`,
56 trail: html`<form class="row-fields" method="post" action="${base}/${f.absorbed ? "unabsorb" : "absorb"}">
61 trail: html`${when(f.pending !== null, () => html`<form class="row-fields" method="post" action="${base}/accept">
62 <input type="hidden" name="name" value="${f.pending?.name ?? ""}" />
63 <input type="hidden" name="page" value="${f.pending?.page ?? ""}" />
64 ${btn("Accept", { size: "sm", label: `Accept ${f.pending?.name ?? ""} as ${f.name}'s menu entry` })}
65 </form>`)}<form class="row-fields" method="post" action="${base}/${f.absorbed ? "unabsorb" : "absorb"}">
5766 ${when(f.absorbed, () => btn("Settings", { size: "sm", href: base }))}
5867 ${btn(f.absorbed ? "Un-absorb" : "Absorb", {
5968 size: "sm",
@@ -235,3 +244,27 @@ export function featureSwitch(absorbing: boolean) {
235244 );
236245 };
237246 }
247
248 // POST /settings/features/:slug/accept
249 export async function featureAccept(ctx: Ctx): Promise<Response> {
250 const no = requireAdmin(ctx);
251 if (no) return no;
252 const form = await readForm(ctx, "admin");
253 if (form instanceof Response) return form;
254 const reviewed = { name: String(form.get("name") ?? ""), page: String(form.get("page") ?? "") };
255 const done = await acceptDeclaration(ctx.app, ctx.auth.user, ctx.params["slug"] ?? "", reviewed);
256 if (!done.ok && done.error === "not_found") return notFoundPage(ctx);
257 return flashTo(
258 ctx,
259 "/settings/features",
260 done.ok
261 ? { message: `${done.value.name} is now the menu's entry for this app.` }
262 : {
263 tone: "danger",
264 message:
265 done.error === "declaration_changed"
266 ? "The app declared something else since you looked. Review the new declaration and accept again."
267 : "That app is not absorbed, or no longer declares a feature.",
268 },
269 );
270 }
src/web/ui.ts +3
@@ -1099,6 +1099,8 @@ export interface MenuItem {
10991099 icon?: IconName;
11001100 current?: boolean;
11011101 separator?: boolean;
1102 /** A non-interactive group label. */
1103 heading?: boolean;
11021104 /** Renders as a submit button inside its own POST form. */
11031105 post?: string;
11041106 /** Shown but not usable: the reason, in words, under the label. */
@@ -1139,6 +1141,7 @@ export function menu(o: {
11391141 if (item instanceof Html) return item;
11401142 const i = item;
11411143 if (i.separator) return html`<hr class="menu-sep" />`;
1144 if (i.heading) return html`<span class="menu-title">${i.label}</span>`;
11421145 if (i.disabled !== undefined) {
11431146 return html`<span class="menu-item" aria-disabled="true" title="${i.disabled}"
11441147 >${i.icon ? icon(i.icon) : null}<span class="grow"
test/apps-features.test.ts +4 −7
@@ -175,14 +175,11 @@ describe("absorbing a running app", () => {
175175 }, 60_000);
176176
177177 test("a preview's forge.json does not rename the forge's menu entry", () => {
178 featureVarsAt(forge.app.db, record.id, forgeJson("Not Radar"), {
179 production: false,
180 });
178 featureVarsAt(forge.app.db, record.id, forgeJson("Not Radar"));
179 expect(getAbsorbed(forge.app.db, record.id)?.name).toBe("Radar");
180 featureVarsAt(forge.app.db, record.id, forgeJson("Radar 2"));
181 // A production push does not rename it either: an admin accepts.
181182 expect(getAbsorbed(forge.app.db, record.id)?.name).toBe("Radar");
182 featureVarsAt(forge.app.db, record.id, forgeJson("Radar 2"), {
183 production: true,
184 });
185 expect(getAbsorbed(forge.app.db, record.id)?.name).toBe("Radar 2");
186183 });
187184
188185 test("un-absorbed, it restarts told nothing again", async () => {
test/features.test.ts +94 −1
@@ -15,7 +15,7 @@ import { join } from "node:path";
1515 import { statements } from "./helpers/statements.ts";
1616 import { expectWork } from "./helpers/work.ts";
1717 import { createApp, type App } from "../src/app.ts";
18 import { absorbApp, absorbedForNav, declaredFeature, changeFeatureSettings, getFeature, unabsorbApp } from "../src/actions/features.ts";
18 import { absorbApp, acceptDeclaration, absorbedForNav, declaredFeature, changeFeatureSettings, getFeature, unabsorbApp } from "../src/actions/features.ts";
1919 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
2020 import { checkSetting, featureEnv, parseFeature, settingEnvName } from "../src/apps/feature.ts";
2121 import { featureVarsAt } from "../src/apps/feature_env.ts";
@@ -266,6 +266,99 @@ describe("absorbing an app", () => {
266266 expect(strangers).not.toContain("Radar");
267267 });
268268
269 test("a production push that renames the feature leaves the menu unchanged until an admin accepts", async () => {
270 const renamed = JSON.stringify({ app: { run: "x" }, feature: { ...FEATURE, name: "Radar Pro", page: "/pro" } });
271 featureVarsAt(app.db, appId, renamed);
272 expect(absorbedForNav(app, ada).map((f) => [f.name, f.page])).toEqual([["Radar", "/"]]);
273 const c = await commitToBranch(app, cy, repo, { message: "rename", files: [{ path: "forge.json", content: renamed }] });
274 if (!c.ok) throw new Error(JSON.stringify(c.error));
275 const view = await getFeature(app, ada, "radar");
276 expect(view.ok && view.value.pending).toEqual({ name: "Radar Pro", page: "/pro" });
277 expect(view.ok && view.value.name).toBe("Radar");
278 const list = await (await web(adaCookie, "/settings/features")).text();
279 expect(list).toContain("declaration changed: review");
280 // The owner is not a site admin; only an admin accepts.
281 const refused = await acceptDeclaration(app, cy, "radar", { name: "Radar Pro", page: "/pro" });
282 expect(!refused.ok && refused.error).toBe("forbidden");
283 expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar");
284 expect((await web(adaCookie, "/settings/features/radar/accept", new URLSearchParams({ name: "Radar Pro", page: "/pro" }))).status).toBe(303);
285 expect(absorbedForNav(app, ada).map((f) => [f.name, f.page])).toEqual([["Radar Pro", "/pro"]]);
286 expect(listEvents(app.db, { repoId: repo.id }).filter((e) => e.kind === "app.feature_declared")).toHaveLength(1);
287 // Back to the original, accepted the same way.
288 await commitToBranch(app, cy, repo, { message: "back", files: [{ path: "forge.json", content: FORGE_JSON }] });
289 await acceptDeclaration(app, ada, "radar", { name: "Radar", page: "/" });
290 expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar");
291 });
292
293 test("accepting what the admin reviewed is refused when production declared something else since", async () => {
294 const declare = (name: string) =>
295 commitToBranch(app, cy, repo, { message: name, files: [{ path: "forge.json", content: JSON.stringify({ app: { run: "x" }, feature: { ...FEATURE, name } }) }] });
296 await declare("Reviewed");
297 await declare("Sneaky");
298 const stale = await acceptDeclaration(app, ada, "radar", { name: "Reviewed", page: FEATURE.page });
299 expect(!stale.ok && stale.error).toBe("declaration_changed");
300 expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar");
301 const res = await web(adaCookie, "/settings/features/radar/accept", new URLSearchParams({ name: "Reviewed", page: FEATURE.page }));
302 expect(res.status).toBe(303);
303 expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar");
304 const fresh = await acceptDeclaration(app, ada, "radar", { name: "Sneaky", page: FEATURE.page });
305 expect(fresh.ok).toBe(true);
306 await commitToBranch(app, cy, repo, { message: "back", files: [{ path: "forge.json", content: FORGE_JSON }] });
307 await acceptDeclaration(app, ada, "radar", { name: "Radar", page: "/" });
308 });
309
310 test("the terminal accepts under the page's rules: admin only, and what the screen showed", async () => {
311 const { featuresScreen, entry } = await import("../src/tui/screens/features.ts");
312 const { Screen: Surface } = await import("../src/tui/term/screen.ts");
313 const { theme } = await import("../src/tui/ui/theme.ts");
314 const declare = (name: string) =>
315 commitToBranch(app, cy, repo, { message: name, files: [{ path: "forge.json", content: JSON.stringify({ app: { run: "x" }, feature: { ...FEATURE, name } }) }] });
316 await declare("Radar TUI");
317 const toasts: string[] = [];
318 const ctx = { theme: theme("dark"), invalidate() {}, push() {}, pop() {}, copy() {}, toast: (m: string) => toasts.push(m) } as never;
319 const open = async (user: typeof ada | typeof cy) => {
320 const screen = featuresScreen({ app, db: app.db, user } as never, ctx);
321 await screen.busy();
322 return screen;
323 };
324 expect(entry.offered({ app, db: app.db, user: cy } as never)).toBe(false);
325 expect(entry.offered({ app, db: app.db, user: ada } as never)).toBe(true);
326 const shown = new Surface(110, 20);
327 (await open(ada)).render({ x: 0, y: 0, w: 110, h: 20 }, shown);
328 expect(shown.toText()).toContain("Radar TUI");
329 const denied = await open(cy);
330 expect(denied.key!({ key: "a" } as never)).toBe(false);
331 expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar");
332 const screen = await open(ada);
333 // The app declares something else after the screen was read.
334 await declare("Radar Later");
335 expect(screen.key!({ key: "a" } as never)).toBe(true);
336 await screen.busy();
337 expect(toasts.at(-1)).toContain("review it again");
338 expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar");
339 const again = await open(ada);
340 expect(again.key!({ key: "a" } as never)).toBe(true);
341 await again.busy();
342 expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar Later");
343 await commitToBranch(app, cy, repo, { message: "back", files: [{ path: "forge.json", content: FORGE_JSON }] });
344 await acceptDeclaration(app, ada, "radar", { name: "Radar", page: "/" });
345 });
346
347 test("every label in the forge's own menus is one a feature may not take", async () => {
348 const { readFileSync } = await import("node:fs");
349 const src = readFileSync(new URL("../src/web/layout.ts", import.meta.url), "utf8");
350 const labels = [...src.matchAll(/\blabel: "([^"]+)"/g)].map((m) => m[1]!);
351 expect(labels.length).toBeGreaterThan(15);
352 for (const l of labels) expect({ l, refused: !parseFeature({ feature: { name: l } }).ok }).toEqual({ l, refused: true });
353 });
354
355 test("the menu groups feature entries under their own heading, and a built-in label is refused", async () => {
356 const home = await (await web(adaCookie, "/settings")).text();
357 expect(home).toMatch(/menu-title">Features from apps<\/span>[\s\S]*Radar/);
358 for (const name of ["Sign out", "admin", " SIGN-OUT ", "Settings"]) expect(parseFeature({ feature: { name } }).ok).toBe(false);
359 expect(parseFeature({ feature: { name: "Settings" } }).ok).toBe(false);
360 });
361
269362 test("a value that does not fit changes nothing, and names the setting", async () => {
270363 const res = await api("PATCH", adaToken, "/radar/settings", { settings: { runDays: ["mon"], runHourUtc: 30 } });
271364 expect(res.status).toBe(422);
test/palette-roster.test.ts +1
@@ -139,6 +139,7 @@ test("a site-admin person is offered the whole roster", () => {
139139 ["origins", "Connected origins", "the hosts your mirrors come from"],
140140 ["domains", "Failure domains", "failure domains and their last drill"],
141141 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
142 ["feature-review", "Menu entries to review", "absorbed apps whose declared name or page changed: accept them"],
142143 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
143144 ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"],
144145 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs