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

Absorb: finding and removing a feature, and how the menu shows one #9644

Merged opened this

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

Merged

mason/forge:mason/9544-absorb-finding-and-removing-a-feature-an → main 7 commits fork 3 threads · 3 unresolved

  • Merged. mason/9544-absorb-finding-and-removing-a-feature-an was merged into main as edb6a89.

Review

Sign in to review this change.
9 files changed +345 −32
docs/features/absorb.md +4
@@ -84,6 +84,10 @@ Absorb was built by Ben (benjivers). The review of #9441 found:
8484
8585 This section is ready for the owner to share with Ben.
8686
87 ## Finding it, and what the switch does
88
89 A site admin sees a "Features" link on the app strip of an absorbed app (an app not yet absorbed is reached from Settings → Features: finding one that merely declares a feature would cost a git read on every app page). The same list, switch and settings are in the terminal: the "Features" palette entry. Beside Absorb or Un-absorb, a line says what restarts and whose menu gains or loses the entry. A refused settings save re-draws the form with what was typed and marks the refused field.
outdated

Says the summary is the menu tooltip; the tooltip was removed. Fix with the code (finding 2).

90
8791 ## The menu entry is the admin's, not the deploy's
8892
8993 The name and page in the menu are the ones the admin saw when absorbing. If the production
src/actions/features.ts +3 −3
@@ -290,8 +290,8 @@ export async function changeFeatureSettings(
290290 actor: User | undefined,
291291 slug: string,
292292 values: Readonly<Record<string, unknown>>,
293 ): Promise<Result<FeatureView, { error: FeatureError; message: string }>> {
294 const refuse = (error: FeatureError, message: string) => err({ error, message });
293 ): Promise<Result<FeatureView, { error: FeatureError; message: string; key?: string }>> {
294 const refuse = (error: FeatureError, message: string, key?: string) => err(key === undefined ? { error, message } : { error, message, key });
295295 const found = readable(app, actor, slug);
296296 if (found === undefined) return refuse("not_found", "No such app.");
297297 if (!siteAdmin(actor)) return refuse("forbidden", "Only a site admin changes a feature's settings.");
@@ -303,7 +303,7 @@ export async function changeFeatureSettings(
303303 const spec = declared.value.settings.find((s) => s.key === key);
304304 if (spec === undefined) return refuse("invalid_setting", `${key} is not one of this feature's settings.`);
305305 const checked = checkSetting(spec, raw);
306 if (!checked.ok) return refuse("invalid_setting", `${spec.label} ${checked.error}.`);
306 if (!checked.ok) return refuse("invalid_setting", `${spec.label} ${checked.error}.`, key);
307307 changes.set(key, checked.value);
308308 }
309309 const before = featureSettings(app.db, found.record.id);
src/tui/screens/entries.ts +2
@@ -27,6 +27,7 @@ import { entry as edgeOnly } from "./edgeonly.ts";
2727 import { entry as traffic } from "./trafficview.ts";
2828 import { entry as featureReview } from "./features.ts";
2929 import { entry as feed } from "./feed.ts";
30 import { entry as features } from "./featureswitch.ts";
3031 import { entry as forgeView } from "./forgeview.ts";
3132 import { entry as flowsAll } from "./flowsall.ts";
3233 import { entry as front } from "./front.ts";
@@ -117,6 +118,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
117118 domains,
118119 feed,
119120 featureReview,
121 features,
120122 front,
121123 owner,
122124 forges,
src/tui/screens/featureswitch.ts added +138
@@ -0,0 +1,138 @@
1 /**
2 * The features list in the terminal, a site admin's who is a person: the page's list
3 * (Settings → Features) from the same `listFeatures`, `a` absorbs or
4 * un-absorbs the one under the cursor, Enter opens its settings and a setting
5 * is typed in and saved through `changeFeatureSettings`, which checks it
6 * against the app's declaration exactly as the page does. Anyone else is not
7 * offered it, and the actions refuse them anyway.
8 */
9
10 import {
11 absorbApp,
12 changeFeatureSettings,
13 listFeatures,
14 unabsorbApp,
15 type FeatureView,
16 type SettingView,
17 } from "../../actions/features.ts";
18 import type { Ctx, Screen } from "../app/app.ts";
19 import { binding } from "../app/keys.ts";
20 import type { Session } from "../session.ts";
21 import { ListState, drawList, promptScreen, runAsync, type ListRow } from "./common.ts";
22 import type { PaletteEntry } from "./entries.ts";
23
24 /** Site admins who are people: what the page's `requireAdmin` lets in. */
25 export const mayFeatures = (f: Session): boolean => f.user !== undefined && f.user.isAdmin && f.user.kind !== "agent";
26
27 const FAILS: Record<string, string> = {
28 not_found: "no such app",
29 forbidden: "only a site admin does that",
30 not_a_feature: "that app's forge.json declares no feature",
31 not_absorbed: "that app is not absorbed",
32 };
33
34 function featureRow(f: FeatureView): ListRow {
35 return {
36 title: `${f.name} ${f.repo}`,
37 meta: f.absorbed ? "absorbed" : "not absorbed",
38 note: f.problem ?? `${String(f.settings.length)} setting${f.settings.length === 1 ? "" : "s"}`,
39 };
40 }
41
42 function settingRow(s: SettingView): ListRow {
43 return { title: `${s.label} ${s.env}`, meta: s.value === "" ? "empty" : s.value, note: `${s.help ?? s.type}${s.set ? "" : " · not changed"}` };
44 }
45
46 function settingsScreen(f: Session, ctx: Ctx, slug: string): Screen {
47 const state = new ListState();
48 let view: FeatureView | undefined;
49 const reload = async (): Promise<void> => {
50 const listed = await listFeatures(f.app, f.user);
51 view = listed.ok ? listed.value.find((v) => v.slug === slug) : undefined;
52 ctx.invalidate();
53 };
54 const screen: Screen = {
55 title: "Feature settings",
56 keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "change")],
57 load: reload,
58 render(area, s) {
59 drawList(s, area, ctx.theme.c, state, (view?.settings ?? []).map(settingRow));
60 },
61 key(e) {
62 const settings = view?.settings ?? [];
63 if (e.key !== "enter") return state.key(e, settings.length);
64 const setting = settings[state.index];
65 if (view === undefined || setting === undefined) return true;
66 if (!view.absorbed) {
67 ctx.toast(`absorb ${view.name} to change its settings`, "attn");
68 return true;
69 }
70 ctx.push(
71 promptScreen(ctx, screen, {
72 title: setting.label,
73 label: setting.type === "list" || setting.type === "days" ? "comma separated" : setting.type,
74 value: setting.value,
75 hint: setting.help ?? `${setting.env}; default ${setting.default === "" ? "empty" : setting.default}`,
76 onSubmit: async (text) => {
77 const saved = await changeFeatureSettings(f.app, f.user, slug, { [setting.key]: text });
78 if (!saved.ok) {
79 ctx.toast(saved.error.message, "attn");
80 return;
81 }
82 ctx.toast(`${saved.value.name}: saved. It restarts with the new settings.`);
83 await reload();
84 },
85 }),
86 );
87 return true;
88 },
89 };
90 return screen;
91 }
92
93 export function featureSwitchScreen(f: Session, ctx: Ctx): Screen {
94 const state = new ListState();
95 let features: readonly FeatureView[] = [];
96 const reload = async (): Promise<void> => {
97 const listed = mayFeatures(f) ? await listFeatures(f.app, f.user) : undefined;
98 features = listed?.ok ? listed.value : [];
99 ctx.invalidate();
100 };
101 const screen: Screen = {
102 title: "Features",
103 keys: [binding(["j", "k"], "move cursor"), binding(["a"], "absorb / un-absorb"), binding(["enter"], "settings")],
104 load: reload,
105 render(area, s) {
106 drawList(s, area, ctx.theme.c, state, features.map(featureRow));
107 },
108 key(e) {
109 const here = features[state.index];
110 if (e.key === "a" && here !== undefined) {
111 runAsync(ctx, async () => {
112 const done = here.absorbed ? await unabsorbApp(f.app, f.user, here.slug) : await absorbApp(f.app, f.user, here.slug);
113 ctx.toast(
114 done.ok ? (here.absorbed ? `${here.name} runs as an ordinary app again; its settings are kept.` : `${here.name} is absorbed: it is in the forge's nav.`) : (FAILS[done.error] ?? done.error),
115 done.ok ? undefined : "attn",
116 );
117 await reload();
118 });
119 return true;
120 }
121 if (e.key === "enter" && here !== undefined) {
122 ctx.push(settingsScreen(f, ctx, here.slug));
123 return true;
124 }
125 return state.key(e, features.length);
126 },
127 };
128 return screen;
129 }
130
131 /** Features, as Settings → Features shows them: a site admin's who is a person. */
132 export const entry: PaletteEntry = {
133 id: "features",
134 title: "Features",
135 hint: "apps that declare a feature: absorb one, change its settings",
136 offered: mayFeatures,
137 run: (session, ctx) => ctx.push(featureSwitchScreen(session, ctx)),
138 };
src/web/pages/apps.ts +5
@@ -12,6 +12,7 @@
1212 * 404 for everyone.
1313 */
1414
15 import { getAbsorbed } from "../../core/features.ts";
1516 import { storageLimitMessage } from "../../core/storage.ts";
1617 import { repositoryLimitMessage } from "../../actions/repos.ts";
1718 import { appLimitWords } from "../../actions/app_limit.ts";
@@ -607,6 +608,10 @@ export function appStrip(ctx: Ctx, repo: Repo): Html {
607608 >`,
608609 )}
609610 ${when(pace, (line) => html`<span class="muted">${line}</span>`)}
611 ${when(
612 ctx.auth.user?.isAdmin === true && ctx.auth.user.kind !== "agent" && getAbsorbed(ctx.app.db, record.id) !== undefined,
613 () => html`<a href="/settings/features/${encodeURIComponent(record.slug)}" title="This app is one of the forge's features">Features</a>`,
614 )}
610615 </div>`;
611616 }
612617
src/web/pages/features.ts +58 −29
@@ -21,7 +21,7 @@ import type { Ctx } from "../../http/context.ts";
2121 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
2222 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
2323 import { readForm, type Form } from "../form.ts";
24 import { html, when } from "../html.ts";
24 import { html, raw, when } from "../html.ts";
2525 import { flashTo, page } from "../layout.ts";
2626 import { badge, btn, card, empty, icon, row } from "../ui.ts";
2727 import { accountSections } from "./origins.ts";
@@ -32,6 +32,9 @@ function requireAdmin(ctx: Ctx): Response | undefined {
3232 return undefined;
3333 }
3434
35 /** The guide for app authors and admins. */
36 const DOCS = "/trevato/forge/blob/main/docs/features/absorb.md";
37
3538 const DAY_LABEL: Record<string, string> = { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri", sat: "Sat", sun: "Sun" };
3639
3740 function shell(ctx: Ctx, title: string, body: ReturnType<typeof html>): Response {
@@ -43,6 +46,11 @@ function shell(ctx: Ctx, title: string, body: ReturnType<typeof html>): Response
4346 });
4447 }
4548
outdated

DESIGN 1/8: one plain sentence per row is the most this note can be; today it is said on every row and again by the button label. Keep it short, and check it at 360px.

49 /** What the switch does, in one sentence. */
50 export function switchNote(f: Pick<FeatureView, "name" | "absorbed">): string {
51 return f.absorbed ? `Un-absorbing takes ${f.name} off every menu; its settings are kept.` : `Absorbing restarts ${f.name} and adds it to the menu.`;
52 }
53
4654 function featureRow(f: FeatureView) {
4755 const base = `/settings/features/${encodeURIComponent(f.slug)}`;
4856 return row({
@@ -50,19 +58,19 @@ function featureRow(f: FeatureView) {
5058 title: html`${f.name} <span class="muted small mono">${f.repo}</span>
5159 ${f.absorbed ? badge("absorbed", { tone: "success" }) : null}
5260 ${f.pending !== null ? badge("declaration changed: review", { tone: "attn" }) : null}`,
53 meta: f.pending !== null
61 meta: html`${f.pending !== null
5462 ? html`Menu: <strong>${f.name}</strong> at <span class="mono">${f.page}</span> → app: <strong>${f.pending.name}</strong> at
5563 <span class="mono">${f.pending.page}</span>`
5664 : f.problem !== null
5765 ? html`<span class="danger">${f.problem}</span>`
5866 : html`${f.summary ?? ""}${when(f.summary !== null, () => html`<br />`)}${f.settings.length} setting${f.settings.length === 1 ? "" : "s"}${f.absorbed
5967 ? html` · absorbed by ${f.absorbedBy ?? "someone"}`
60 : html` · not absorbed: the app runs as an ordinary app`}`,
68 : null}`}<br /><span id="sw-${f.slug}">${switchNote(f)}</span>`,
6169 trail: html`${when(f.pending !== null, () => html`<form class="row-fields" method="post" action="${base}/accept">
6270 <input type="hidden" name="name" value="${f.pending?.name ?? ""}" />
6371 <input type="hidden" name="page" value="${f.pending?.page ?? ""}" />
6472 ${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"}">
73 </form>`)}<form class="row-fields" method="post" action="${base}/${f.absorbed ? "unabsorb" : "absorb"}" aria-describedby="sw-${f.slug}">
6674 ${when(f.absorbed, () => btn("Settings", { size: "sm", href: base }))}
6775 ${btn(f.absorbed ? "Un-absorb" : "Absorb", {
6876 size: "sm",
@@ -81,7 +89,7 @@ export async function featuresPage(ctx: Ctx): Promise<Response> {
8189 const listed = await timePhase(ctx.request, "features", () => listFeatures(ctx.app, ctx.auth.user));
8290 if (!listed.ok) return notFoundPage(ctx);
8391 const rendering = performance.now();
84 const body = card(
92 const body = html`${card(
8593 listed.value.length === 0
8694 ? html`<div class="card-body">
8795 ${empty({
@@ -98,14 +106,15 @@ export async function featuresPage(ctx: Ctx): Promise<Response> {
98106 <h2>Features</h2>`,
99107 pad: false,
100108 },
101 );
109 )}${when(listed.value.length === 0, () => html`<p class="muted small"><a href="${DOCS}">How an app declares a feature</a></p>`)}`;
102110 const response = shell(ctx, "Features", body);
103111 notePhase(ctx.request, "render", performance.now() - rendering);
104112 return response;
105113 }
106114
107 function input(s: SettingView) {
115 function input(s: SettingView, invalid = false) {
108116 const name = `s.${s.key}`;
117 const bad = invalid ? raw('aria-invalid="true"') : null;
109118 switch (s.type) {
110119 case "bool":
111120 return html`<input type="hidden" name="${name}" value="off" /><label class="check"
@@ -114,9 +123,9 @@ function input(s: SettingView) {
114123 case "int":
115124 return html`<input class="input mono" type="number" name="${name}" value="${s.value}"
116125 ${s.min === null ? "" : html`min="${String(s.min)}"`} ${s.max === null ? "" : html`max="${String(s.max)}"`}
117 aria-label="${s.label}" />`;
126 aria-label="${s.label}" ${bad} />`;
118127 case "choice":
119 return html`<select class="input" name="${name}" aria-label="${s.label}">
128 return html`<select class="input" name="${name}" aria-label="${s.label}" ${bad}>
120129 ${(s.choices ?? []).map((c) => html`<option value="${c}" ${c === s.value ? "selected" : ""}>${c}</option>`)}
121130 </select>`;
122131 case "days": {
@@ -126,9 +135,9 @@ function input(s: SettingView) {
126135 )}`;
127136 }
128137 case "list":
129 return html`<textarea class="input mono" name="${name}" rows="3" aria-label="${s.label}">${s.value.split(",").filter(Boolean).join("\n")}</textarea>`;
138 return html`<textarea class="input mono" name="${name}" rows="3" aria-label="${s.label}" ${bad}>${s.value.split(",").filter(Boolean).join("\n")}</textarea>`;
130139 default:
131 return html`<input class="input" name="${name}" value="${s.value}" aria-label="${s.label}" />`;
140 return html`<input class="input" name="${name}" value="${s.value}" aria-label="${s.label}" ${bad} />`;
132141 }
133142 }
134143
@@ -139,18 +148,41 @@ export async function featurePage(ctx: Ctx): Promise<Response> {
139148 if (no) return no;
140149 const found = await timePhase(ctx.request, "feature", () => getFeature(ctx.app, ctx.auth.user, ctx.params["slug"] ?? ""));
141150 if (!found.ok) return notFoundPage(ctx);
142 const f = found.value;
143151 const rendering = performance.now();
152 const response = featureView(ctx, found.value);
153 notePhase(ctx.request, "render", performance.now() - rendering);
154 return response;
155 }
156
157 /** What a refused save hands back: the values as typed, and the one that was refused. */
158 interface Refused {
159 readonly typed: Readonly<Record<string, unknown>>;
160 readonly key: string | undefined;
161 readonly message: string;
162 }
163
164 /** A setting's value as the form sent it, in the shape `input` draws. */
165 function typedValue(s: SettingView, raw: unknown): string {
166 if (Array.isArray(raw)) return raw.map(String).join(",");
167 const text = String(raw ?? "");
168 if (s.type === "bool") return text === "on" ? "true" : "false";
169 if (s.type === "list") return text.split(/[\n,]/).map((t) => t.trim()).filter(Boolean).join(",");
170 return text;
171 }
172
173 function featureView(ctx: Ctx, f: FeatureView, refused?: Refused): Response {
144174 const base = `/settings/features/${encodeURIComponent(f.slug)}`;
145 const settings = f.settings.map((s) =>
146 row({
175 const settings = f.settings.map((shown) => {
176 const bad = refused?.key === shown.key;
177 const s = refused !== undefined && shown.key in refused.typed ? { ...shown, value: typedValue(shown, refused.typed[shown.key]) } : shown;
178 return row({
147179 lead: icon("settings"),
148180 title: html`${s.label} <span class="muted small mono">${s.env}</span>`,
149 meta: html`${s.help ?? ""}${when(s.help !== null, () => html`<br />`)}Default
181 meta: html`${bad ? html`<span class="danger" role="alert">${refused.message}</span><br />` : null}${s.help ?? ""}${when(s.help !== null, () => html`<br />`)}Default
150182 <span class="mono">${s.default === "" ? "empty" : s.default}</span>${s.set ? "" : " · not changed"}`,
151 trail: input(s),
152 }),
153 );
183 trail: input(s, bad),
184 });
185 });
154186 const body = html`${card(
155187 f.settings.length === 0
156188 ? html`<div class="card-body"><p class="muted small">${f.problem ?? "This feature declares no settings."}</p></div>`
@@ -173,9 +205,8 @@ export async function featurePage(ctx: Ctx): Promise<Response> {
173205 Saving restarts ${f.name}: its process reads each setting as an environment variable, named on the right.
174206 <a href="/settings/features">All features</a>
175207 </p>`;
176 const response = shell(ctx, f.name, body);
177 notePhase(ctx.request, "render", performance.now() - rendering);
178 return response;
208 const answered = shell(ctx, f.name, html`${refused !== undefined && refused.key === undefined ? html`<p class="danger" role="alert">${refused.message}</p>` : null}${body}`);
209 return refused === undefined ? answered : new Response(answered.body, { status: 422, headers: answered.headers });
179210 }
180211
181212 /** The form's settings, as the action takes them: `days` from its checkboxes, the rest as sent. */
@@ -201,16 +232,14 @@ export async function featureSave(ctx: Ctx): Promise<Response> {
201232 const slug = ctx.params["slug"] ?? "";
202233 const found = await getFeature(ctx.app, ctx.auth.user, slug);
203234 if (!found.ok) return notFoundPage(ctx);
204 const changed = await changeFeatureSettings(ctx.app, ctx.auth.user, slug, settingsFromForm(form, found.value.settings));
235 const typed = settingsFromForm(form, found.value.settings);
236 const changed = await changeFeatureSettings(ctx.app, ctx.auth.user, slug, typed);
205237 const back = `/settings/features/${encodeURIComponent(slug)}`;
206238 if (!changed.ok && changed.error.error === "not_found") return notFoundPage(ctx);
207 return flashTo(
208 ctx,
209 back,
210 changed.ok
211 ? { message: `${changed.value.name}: saved. It restarts with the new settings.` }
212 : { tone: "danger", message: changed.error.message },
213 );
239 if (changed.ok) return flashTo(ctx, back, { message: `${changed.value.name}: saved. It restarts with the new settings.` });
240 if (changed.error.error === "invalid_setting")
241 return featureView(ctx, found.value, { typed, key: changed.error.key, message: changed.error.message });
242 return flashTo(ctx, back, { tone: "danger", message: changed.error.message });
214243 }
215244
216245 // POST /settings/features/:slug/absorb and /unabsorb
test/features-tui.test.ts added +115
@@ -0,0 +1,115 @@
1 /**
2 * Features in the terminal (#9544): the page's list, its switch and its
3 * settings, through the same actions. A site admin finds the entry, absorbs
4 * and un-absorbs, and a setting is saved or refused as on the page; anyone
5 * else is not offered it.
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 { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App as Forge } from "../src/app.ts";
13 import { createApp as insertApp } from "../src/core/apps.ts";
14 import { featureSettings, getAbsorbed } from "../src/core/features.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import type { TuiIdentity } from "../src/tui/session.ts";
18 import { headlessTerminal } from "../src/tui/term/terminal.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { settleApp } from "./helpers/tui.ts";
21
22 quietLogs();
23
24 const FORGE_JSON = JSON.stringify({
25 app: { run: "bun run src/server.ts" },
26 feature: { name: "Radar", page: "/", settings: { runHourUtc: { type: "int", default: 13, min: 0, max: 23, label: "Hour (UTC)" } } },
27 });
28
29 describe("features in the terminal", () => {
30 let dir: string;
31 let forge: Forge;
32 let ada: User;
33 let eve: User;
34 let appId: string;
35
36 function boot(user: User) {
37 const terminal = headlessTerminal(120, 32);
38 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
39 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
40 app.start();
41 const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
42 const press = async (k: string) => {
43 key(k);
44 await settleApp(app);
45 await settleApp(app);
46 };
47 const type = async (text: string) => {
48 for (const ch of text) key(ch);
49 await settleApp(app);
50 };
51 const palette = async () => {
52 await settleApp(app);
53 app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
54 await settleApp(app);
55 await type("Features");
56 };
57 return { text: () => app.screen.toText(), press, type, palette, stop: () => app.stop() };
58 }
59
60 beforeAll(async () => {
61 dir = await mkdtemp(join(tmpdir(), "forge-features-tui-"));
62 forge = createApp({ dataDir: dir, port: 0 });
63 const make = async (username: string, adminIfFirst: boolean): Promise<User> => {
64 const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst });
65 if (!made.ok) throw new Error(made.error);
66 return made.value;
67 };
68 ada = await make("ada", true);
69 eve = await make("eve", false);
70 const repo = await createRepo(forge, ada, { name: "radar", owner: "ada", visibility: "public", description: "" });
71 if (!repo.ok) throw new Error(JSON.stringify(repo.error));
72 const done = await commitToBranch(forge, ada, repo.value, { message: "feature", files: [{ path: "forge.json", content: FORGE_JSON }] });
73 if (!done.ok) throw new Error(JSON.stringify(done.error));
74 const row = insertApp(forge.db, { repoId: repo.value.id, slug: "radar", access: "public" });
75 if (!row.ok) throw new Error(row.error);
76 appId = row.value.id;
77 }, 60_000);
78 afterAll(async () => {
79 forge?.close();
80 await rm(dir, { recursive: true, force: true });
81 });
82
83 test("only a site admin is offered the entry", async () => {
84 const other = boot(eve);
85 await other.palette();
86 expect(other.text()).not.toContain("apps that declare a feature");
87 other.stop();
88 });
89
90 test("an admin absorbs, saves a setting, is refused a bad one, and un-absorbs", async () => {
91 const t = boot(ada);
92 await t.palette();
93 await t.press("enter");
94 expect(t.text()).toContain("Radar");
95 expect(t.text()).toContain("not absorbed");
96 await t.press("a");
97 expect(getAbsorbed(forge.db, appId)).toBeDefined();
98 await t.press("enter");
99 await t.press("enter");
100 for (let i = 0; i < 4; i++) await t.press("backspace");
101 await t.type("9");
102 await t.press("enter");
103 expect(featureSettings(forge.db, appId).get("runHourUtc")).toBe("9");
104 await t.press("enter");
105 for (let i = 0; i < 4; i++) await t.press("backspace");
106 await t.type("99");
107 await t.press("enter");
108 expect(t.text()).toContain("Hour (UTC) is at most 23");
109 expect(featureSettings(forge.db, appId).get("runHourUtc")).toBe("9");
110 await t.press("esc");
111 await t.press("a");
112 expect(getAbsorbed(forge.db, appId)).toBeUndefined();
113 t.stop();
114 });
115 });
test/features.test.ts +19
@@ -370,6 +370,25 @@ describe("absorbing an app", () => {
370370 expect(!byOwner.ok && byOwner.error.error).toBe("forbidden");
371371 });
372372
373 test("a refused save keeps what was typed and marks the field; the menu and strip say more", async () => {
374 const form = new URLSearchParams([
375 ["s.runDays", "tue"],
376 ["s.runHourUtc", "99"],
377 ["s.claudeCode", "stable"],
378 ]);
379 const refused = await web(adaCookie, "/settings/features/radar", form);
380 expect(refused.status).toBe(422);
381 const page = await refused.text();
382 expect(page).toContain('value="99"');
383 expect(page).toContain("Hour (UTC) is at most 23");
384 expect(page).toContain('aria-invalid="true"');
385 // The switch says what it does before it is pressed.
386 const list = await (await web(adaCookie, "/settings/features")).text();
387 expect(list).toContain("Un-absorbing takes Radar off every menu");
388 // The app's own page links to Features for an admin.
389 expect(await (await web(adaCookie, "/cy/radar/files")).text()).toContain('href="/settings/features/radar"');
390 });
391
373392 test("good values are kept and reach the app as FORGE_SETTING_*", async () => {
374393 const res = await api("PATCH", adaToken, "/radar/settings", { settings: { runDays: ["wed", "mon", "fri"], runHourUtc: 9 } });
375394 expect(res.status).toBe(200);
test/palette-roster.test.ts +1
@@ -142,6 +142,7 @@ test("a site-admin person is offered the whole roster", () => {
142142 ["domains", "Failure domains", "failure domains and their last drill"],
143143 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
144144 ["feature-review", "Menu entries to review", "absorbed apps whose declared name or page changed: accept them"],
145 ["features", "Features", "apps that declare a feature: absorb one, change its settings"],
145146 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
146147 ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"],
147148 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs