Public: anyone can read this; the crew can change it.
Settings › Features' Scheduled rows have Run now: one call of the tool, recorded as its fire #10117
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10108-settings-features-scheduled-rows-have-ru → main 3 commits fork 1 thread · 1 unresolved
8 files
changed +316
−5
docs/features/feature-schedule.md +3 −3
| @@ -33,10 +33,10 @@ One row per (app, tool) in `feature_fire` (`src/core/feature_fires.ts`), replace | ||
| 33 | 33 | |
| 34 | 34 | ## Read it |
| 35 | 35 | |
| 36 | - **Page:** Settings › Features › the app, under **Scheduled** (a site admin's page, as the rest of it). | |
| 36 | - **Page:** Settings › Features › the app, under **Scheduled** (a site admin's page, as the rest of it). Each row has **Run now**: the same call, recorded as the tool's fire. A stopped or asleep app is refused with a sentence, never woken; a call already running is refused, never doubled. | |
| 37 | 37 | - **API:** `GET /api/v1/features/:slug` returns `fires`: a list of `{ tool, at, ok, error, durationMs }` (`at` in ms since the epoch). It is empty until something has fired. Readable by whoever may read the feature. |
| 38 | - **Terminal:** Feature feeds › the app: a `Scheduled:` line above the feed items, tool, outcome and age. | |
| 38 | - **Terminal:** Feature feeds › the app: a `Scheduled:` line above the feed items, tool, outcome and age. An admin presses `r` there to run those tools now: the same call, record and refusal sentences as Run now on the page. | |
| 39 | 39 | |
| 40 | 40 | ## Not done |
| 41 | 41 | |
| 42 | No arguments per entry, no history beyond the last fire, no "run now" button, and no wake-on-schedule for a stopped app. | |
| 42 | No arguments per entry, no history beyond the last fire, and no wake-on-schedule for a stopped app. |
src/apps/feature_run.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | /** | |
| 2 | * Run now, in words: one call of a scheduled tool through `fireOne`, and what | |
| 3 | * to tell the person. The page and the terminal both say it through here, so | |
| 4 | * the sentences are one set. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { lastFires } from "../core/feature_fires.ts"; | |
| 9 | import { fireOne, type Reach } from "./feature_schedule.ts"; | |
| 10 | ||
| 11 | export const RUN_REFUSALS = { | |
| 12 | not_absorbed: "Absorb the app before running its tools.", | |
| 13 | not_scheduled: "That tool is not on the app's schedule.", | |
| 14 | asleep: "The app's production is not running; it is not woken for a run. Redeploy it first.", | |
| 15 | in_flight: "That tool is already running.", | |
| 16 | } as const; | |
| 17 | ||
| 18 | /** Call `tool` now and say how it went; `ok` is false for a refusal or a failed call. */ | |
| 19 | export async function runNow(db: Database, appId: string, tool: string, reach: Reach): Promise<{ ok: boolean; message: string }> { | |
| 20 | const fired = await fireOne(db, appId, tool, reach); | |
| 21 | if (!fired.ok) return { ok: false, message: RUN_REFUSALS[fired.why] }; | |
| 22 | const fire = lastFires(db, appId).find((f) => f.tool === tool); | |
| 23 | return fire?.ok === false ? { ok: false, message: `${tool} ran and failed: ${fire.error ?? "failed"}.` } : { ok: true, message: `${tool} ran.` }; | |
| 24 | } |
src/apps/feature_schedule.ts +40
| @@ -67,6 +67,46 @@ export async function fireApp(db: Database, appId: string, reach: Reach, now: () | ||
| 67 | 67 | return called; |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | /** Why a run now was not made. */ | |
| 71 | export type FireOneRefusal = "not_absorbed" | "not_scheduled" | "asleep" | "in_flight"; | |
| 72 | ||
| 73 | /** | |
| 74 | * Call one scheduled tool now, the way the schedule does, and record it as its | |
| 75 | * fire. A stopped or asleep app is refused, never woken; a call already running | |
| 76 | * for the same (app, tool) is refused, never doubled. | |
| 77 | */ | |
| 78 | export async function fireOne( | |
| 79 | db: Database, | |
| 80 | appId: string, | |
| 81 | tool: string, | |
| 82 | reach: Reach, | |
| 83 | now: () => number = Date.now, | |
| 84 | ): Promise<{ ok: true } | { ok: false; why: FireOneRefusal }> { | |
| 85 | if (getAbsorbed(db, appId) === undefined) return { ok: false, why: "not_absorbed" }; | |
| 86 | const key = `${appId}:${tool}`; | |
| 87 | if (inFlight.has(key)) return { ok: false, why: "in_flight" }; | |
| 88 | const port = reach.port(); | |
| 89 | if (port === undefined) return { ok: false, why: "asleep" }; | |
| 90 | inFlight.add(key); | |
| 91 | try { | |
| 92 | const declared = await reach.declared().catch(() => undefined); | |
| 93 | if (declared === undefined || !declared.tools.includes(tool) || !declared.schedule.some((e) => e.tool === tool)) return { ok: false, why: "not_scheduled" }; | |
| 94 | const started = now(); | |
| 95 | // callTool is bounded (WAIT_MS) and answers an error rather than throwing; a throw is still a recorded failure. | |
| 96 | const answer = await callTool(port, tool, {}).catch((): { ok: false; error: string } => ({ ok: false, error: "the app did not answer" })); | |
| 97 | recordFire(db, appId, { | |
| 98 | tool, | |
| 99 | at: started, | |
| 100 | ok: answer.ok, | |
| 101 | error: answer.ok ? null : answer.error, | |
| 102 | durationMs: Math.max(0, now() - started), | |
| 103 | }); | |
| 104 | return { ok: true }; | |
| 105 | } finally { | |
| 106 | inFlight.delete(key); | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 70 | 110 | /** What `fireApp` reaches for one app in the real runtime: only a production that is running, never woken. */ |
| 71 | 111 | export function runtimeReach(app: App, record: AppRecord): Reach { |
| 72 | 112 | return { |
src/http/settings_routes.ts +2 −1
| @@ -31,7 +31,7 @@ import { | ||
| 31 | 31 | signInAct, |
| 32 | 32 | } from "../web/pages/accounts.ts"; |
| 33 | 33 | import { confirmsList, confirmYes } from "../web/pages/confirms.ts"; |
| 34 | import { featureAccept, featurePage, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts"; | |
| 34 | import { featureAccept, featurePage, featureRun, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts"; | |
| 35 | 35 | import { passkeyAdd, passkeyDelete, passkeyOptions, passkeysPage } from "../web/pages/passkeys.ts"; |
| 36 | 36 | import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts"; |
| 37 | 37 | import { |
| @@ -185,6 +185,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 185 | 185 | router.add("GET", "/settings/features", featuresPage); |
| 186 | 186 | router.add("GET", "/settings/features/:slug", featurePage); |
| 187 | 187 | router.add("POST", "/settings/features/:slug", featureSave); |
| 188 | router.add("POST", "/settings/features/:slug/run/:tool", featureRun()); | |
| 188 | 189 | router.add("POST", "/settings/features/:slug/absorb", featureSwitch(true)); |
| 189 | 190 | router.add("POST", "/settings/features/:slug/unabsorb", featureSwitch(false)); |
| 190 | 191 | router.add("POST", "/settings/features/:slug/accept", featureAccept); |
src/tui/screens/featurefeed.ts +24 −1
| @@ -6,6 +6,9 @@ | ||
| 6 | 6 | * do: the viewer's own feed items about that repository, muted or not. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { runNow } from "../../apps/feature_run.ts"; | |
| 10 | import { runtimeReach } from "../../apps/feature_schedule.ts"; | |
| 11 | import { getAppBySlug } from "../../core/apps.ts"; | |
| 9 | 12 | import { absorbedForNav, feedOf, getFeature, type NavFeature } from "../../actions/features.ts"; |
| 10 | 13 | import type { Fired } from "../../core/feature_fires.ts"; |
| 11 | 14 | import type { FeedItem } from "../../feed/rank.ts"; |
| @@ -31,9 +34,28 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc | ||
| 31 | 34 | fires = found.ok ? found.value.fires : []; |
| 32 | 35 | ctx.invalidate(); |
| 33 | 36 | }); |
| 37 | const mayRun = (): boolean => f.user?.isAdmin === true && f.user.kind !== "agent" && fires.length > 0; | |
| 38 | let running = false; | |
| 39 | /** Run every tool on the Scheduled line now, saying the page's sentences. */ | |
| 40 | async function runAll(): Promise<void> { | |
| 41 | const record = getAppBySlug(f.app.db, feature.slug); | |
| 42 | if (record === undefined || running) return; | |
| 43 | running = true; | |
| 44 | try { | |
| 45 | for (const tool of fires.map((x) => x.tool)) { | |
| 46 | const said = await runNow(f.app.db, record.id, tool, runtimeReach(f.app, record)); | |
| 47 | ctx.toast(said.message, said.ok ? "success" : "danger"); | |
| 48 | } | |
| 49 | const found = await getFeature(f.app, f.user, feature.slug); | |
| 50 | fires = found.ok ? found.value.fires : fires; | |
| 51 | } finally { | |
| 52 | running = false; | |
| 53 | ctx.invalidate(); | |
| 54 | } | |
| 55 | } | |
| 34 | 56 | return { |
| 35 | 57 | title: `In the feed: ${feature.name}`, |
| 36 | keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")], | |
| 58 | keys: [binding(["j", "k"], "move"), ...(mayRun() ? [binding(["r"], "run scheduled now")] : []), binding(["esc", "q"], "back")], | |
| 37 | 59 | render(area, s) { |
| 38 | 60 | const c = ctx.theme.c; |
| 39 | 61 | const [head, body] = fires.length === 0 ? [undefined, area] : split(area, "v", [len(1), fill(1)]); |
| @@ -50,6 +72,7 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc | ||
| 50 | 72 | }, |
| 51 | 73 | key(e) { |
| 52 | 74 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 75 | else if (e.key === "r" && mayRun()) void runAll(); | |
| 53 | 76 | else state.key(e, items.length); |
| 54 | 77 | return true; |
| 55 | 78 | }, |
src/web/pages/features.ts +21
| @@ -18,6 +18,9 @@ import { | ||
| 18 | 18 | type SettingView, |
| 19 | 19 | provenanceFacts, |
| 20 | 20 | } from "../../actions/features.ts"; |
| 21 | import { runNow } from "../../apps/feature_run.ts"; | |
| 22 | import { runtimeReach, type Reach } from "../../apps/feature_schedule.ts"; | |
| 23 | import { getAppBySlug, type AppRecord } from "../../core/apps.ts"; | |
| 21 | 24 | import { DAYS } from "../../apps/feature.ts"; |
| 22 | 25 | import type { FeedItem } from "../../feed/rank.ts"; |
| 23 | 26 | import type { Ctx } from "../../http/context.ts"; |
| @@ -226,6 +229,7 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonl | ||
| 226 | 229 | <ul class="list">${f.fires.map((x) => row({ |
| 227 | 230 | title: html`<span class="mono">${x.tool}</span>`, |
| 228 | 231 | meta: html`${x.ok ? "ok" : html`<span class="danger">${x.error ?? "failed"}</span>`} · ${time(new Date(x.at).toISOString())} · ${x.durationMs} ms`, |
| 232 | trail: html`<form method="post" action="${base}/run/${encodeURIComponent(x.tool)}">${btn("Run now", { label: `Run ${x.tool} now` })}</form>`, | |
| 229 | 233 | }))}</ul> |
| 230 | 234 | </section>`)} |
| 231 | 235 | <p class="muted small"> |
| @@ -270,6 +274,23 @@ export async function featureSave(ctx: Ctx): Promise<Response> { | ||
| 270 | 274 | return flashTo(ctx, back, { tone: "danger", message: changed.error.message }); |
| 271 | 275 | } |
| 272 | 276 | |
| 277 | /** POST /settings/features/:slug/run/:tool, with `reachOf` the seam a test replaces. */ | |
| 278 | export function featureRun(reachOf: (app: Ctx["app"], record: AppRecord) => Reach = runtimeReach) { | |
| 279 | return async (ctx: Ctx): Promise<Response> => { | |
| 280 | const no = requireAdmin(ctx); | |
| 281 | if (no) return no; | |
| 282 | const form = await readForm(ctx, "admin"); | |
| 283 | if (form instanceof Response) return form; | |
| 284 | const slug = ctx.params["slug"] ?? ""; | |
| 285 | const tool = ctx.params["tool"] ?? ""; | |
| 286 | const record = getAppBySlug(ctx.app.db, slug); | |
| 287 | if (record === undefined) return notFoundPage(ctx); | |
| 288 | const back = `/settings/features/${encodeURIComponent(slug)}`; | |
| 289 | const said = await runNow(ctx.app.db, record.id, tool, reachOf(ctx.app, record)); | |
| 290 | return flashTo(ctx, back, said.ok ? { message: said.message } : { tone: "danger", message: said.message }); | |
| 291 | }; | |
| 292 | } | |
| 293 | ||
| 273 | 294 | // POST /settings/features/:slug/absorb and /unabsorb |
| 274 | 295 | export function featureSwitch(absorbing: boolean) { |
| 275 | 296 | return async (ctx: Ctx): Promise<Response> => { |
test/feature-run-now.test.ts added +182
| @@ -0,0 +1,182 @@ | ||
| 1 | /** | |
| 2 | * Run now (#10108): an admin runs one scheduled tool from Settings › Features, | |
| 3 | * through the schedule's own call, recorded as its fire. Tried with a fake | |
| 4 | * reach: a good call, a failing one, an asleep app (never reached), a call | |
| 5 | * already running (never doubled); and on a forge with no app runtime, where a | |
| 6 | * non-admin gets the 404 and an admin is told the app is asleep. | |
| 7 | */ | |
| 8 | import { Database } from "bun:sqlite"; | |
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { RUN_REFUSALS, runNow } from "../src/apps/feature_run.ts"; | |
| 16 | import { fireOne, type Reach } from "../src/apps/feature_schedule.ts"; | |
| 17 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 18 | import { lastFires, recordFire } from "../src/core/feature_fires.ts"; | |
| 19 | import { absorb } from "../src/core/features.ts"; | |
| 20 | import { migrationFiles } from "../src/core/schema.ts"; | |
| 21 | import { createUser } from "../src/core/users.ts"; | |
| 22 | import { migrate } from "../src/kernel/migrate.ts"; | |
| 23 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 24 | import { promote } from "./helpers/members.ts"; | |
| 25 | ||
| 26 | const HOUR = 3_600_000; | |
| 27 | const schedule = [{ tool: "scan", everyMs: HOUR }]; | |
| 28 | let seen: string[] = []; | |
| 29 | let answer: () => Response = () => Response.json({ ok: true }); | |
| 30 | let hold: Promise<void> | undefined; | |
| 31 | const stub = Bun.serve({ | |
| 32 | port: 0, | |
| 33 | hostname: "127.0.0.1", | |
| 34 | fetch: async (req) => { | |
| 35 | seen.push(new URL(req.url).pathname); | |
| 36 | if (hold) await hold; | |
| 37 | return answer(); | |
| 38 | }, | |
| 39 | }); | |
| 40 | const reach = (over: Partial<Reach> = {}): Reach => ({ port: () => stub.port, declared: async () => ({ tools: ["scan"], schedule }), ...over }); | |
| 41 | afterAll(() => stub.stop(true)); | |
| 42 | ||
| 43 | describe("fireOne", () => { | |
| 44 | let db: Database; | |
| 45 | const fresh = () => { | |
| 46 | db = new Database(":memory:"); | |
| 47 | migrate(db, migrationFiles); | |
| 48 | absorb(db, "a1", "admin", "Radar", "/"); | |
| 49 | seen = []; | |
| 50 | answer = () => Response.json({ ok: true }); | |
| 51 | hold = undefined; | |
| 52 | }; | |
| 53 | ||
| 54 | test("a good call is made once and recorded as the fire, even inside the interval", async () => { | |
| 55 | fresh(); | |
| 56 | recordFire(db, "a1", { tool: "scan", at: 1, ok: false, error: "old", durationMs: 1 }); | |
| 57 | expect(await fireOne(db, "a1", "scan", reach(), () => 10)).toEqual({ ok: true }); | |
| 58 | expect(seen).toEqual(["/forge/tools/scan"]); | |
| 59 | expect(lastFires(db, "a1")).toEqual([{ tool: "scan", at: 10, ok: true, error: null, durationMs: 0 }]); | |
| 60 | }); | |
| 61 | ||
| 62 | test("a failing call is recorded with its sentence", async () => { | |
| 63 | fresh(); | |
| 64 | answer = () => new Response("no", { status: 500 }); | |
| 65 | expect(await fireOne(db, "a1", "scan", reach(), () => 5)).toEqual({ ok: true }); | |
| 66 | expect(lastFires(db, "a1")[0]).toMatchObject({ ok: false, error: "the app answered 500" }); | |
| 67 | }); | |
| 68 | ||
| 69 | test("an asleep app is refused and not called", async () => { | |
| 70 | fresh(); | |
| 71 | expect(await fireOne(db, "a1", "scan", reach({ port: () => undefined }))).toEqual({ ok: false, why: "asleep" }); | |
| 72 | expect(seen).toEqual([]); | |
| 73 | expect(lastFires(db, "a1")).toEqual([]); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a call already running is refused, not doubled", async () => { | |
| 77 | fresh(); | |
| 78 | let release!: () => void; | |
| 79 | hold = new Promise((r) => (release = r)); | |
| 80 | const first = fireOne(db, "a1", "scan", reach()); | |
| 81 | while (seen.length === 0) await Bun.sleep(1); | |
| 82 | expect(await fireOne(db, "a1", "scan", reach())).toEqual({ ok: false, why: "in_flight" }); | |
| 83 | release(); | |
| 84 | expect(await first).toEqual({ ok: true }); | |
| 85 | expect(seen.length).toBe(1); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a tool not on the schedule, or an app not absorbed, is refused", async () => { | |
| 89 | fresh(); | |
| 90 | expect(await fireOne(db, "a1", "other", reach())).toEqual({ ok: false, why: "not_scheduled" }); | |
| 91 | expect(await fireOne(db, "nope", "scan", reach())).toEqual({ ok: false, why: "not_absorbed" }); | |
| 92 | expect(seen).toEqual([]); | |
| 93 | }); | |
| 94 | }); | |
| 95 | ||
| 96 | describe("what is said", () => { | |
| 97 | const db = new Database(":memory:"); | |
| 98 | migrate(db, migrationFiles); | |
| 99 | absorb(db, "a1", "admin", "Radar", "/"); | |
| 100 | ||
| 101 | test("a good call says ran; a failing one says ran and failed with why; a refusal has its sentence", async () => { | |
| 102 | answer = () => Response.json({ ok: true }); | |
| 103 | expect(await runNow(db, "a1", "scan", reach())).toEqual({ ok: true, message: "scan ran." }); | |
| 104 | answer = () => new Response("no", { status: 500 }); | |
| 105 | expect(await runNow(db, "a1", "scan", reach())).toEqual({ ok: false, message: "scan ran and failed: the app answered 500." }); | |
| 106 | expect(await runNow(db, "a1", "scan", reach({ port: () => undefined }))).toEqual({ ok: false, message: RUN_REFUSALS.asleep }); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a reach that throws is a refusal, and a dead port a recorded failure, never a throw", async () => { | |
| 110 | const boom = reach({ declared: async () => { throw new Error("x"); } }); | |
| 111 | expect(await runNow(db, "a1", "scan", boom)).toEqual({ ok: false, message: RUN_REFUSALS.not_scheduled }); | |
| 112 | expect((await runNow(db, "a1", "scan", reach({ port: () => 1 }))).message).toBe("scan ran and failed: the app did not answer."); | |
| 113 | }); | |
| 114 | }); | |
| 115 | ||
| 116 | describe("the route", () => { | |
| 117 | let dataDir: string; | |
| 118 | let app: App; | |
| 119 | let servers: Servers; | |
| 120 | let appId: string; | |
| 121 | let adaCookie: string; | |
| 122 | let cyCookie: string; | |
| 123 | const signIn = async (username: string): Promise<string> => { | |
| 124 | const r = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username, password: "a-good-passphrase" }), redirect: "manual" }); | |
| 125 | return `${(r.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=dev`; | |
| 126 | }; | |
| 127 | const web = (cookie: string, path: string, post = false) => | |
| 128 | fetch(`${servers.url}${path}`, { method: post ? "POST" : "GET", headers: { cookie, origin: servers.url }, redirect: "manual", ...(post ? { body: new URLSearchParams() } : {}) }); | |
| 129 | ||
| 130 | beforeAll(async () => { | |
| 131 | dataDir = await mkdtemp(join(tmpdir(), "forge-run-now-")); | |
| 132 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 133 | servers = await serve(app); | |
| 134 | const make = async (username: string, adminIfFirst = false) => { | |
| 135 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst }); | |
| 136 | if (!made.ok) throw new Error(made.error); | |
| 137 | return made.value; | |
| 138 | }; | |
| 139 | await make("ada", true); | |
| 140 | const cy = await make("cy"); | |
| 141 | promote(app.db, cy); | |
| 142 | const made = await createRepo(app, cy, { name: "radar", visibility: "private", description: "" }); | |
| 143 | if (!made.ok) throw new Error(String(made.error)); | |
| 144 | const forgeJson = JSON.stringify({ | |
| 145 | app: { run: "bun run src/server.ts" }, | |
| 146 | feature: { name: "Radar", page: "/", tools: { scan: { description: "Scan" } }, schedule: [{ tool: "scan", every: "1h" }] }, | |
| 147 | }); | |
| 148 | const committed = await commitToBranch(app, cy, made.value, { message: "feature", files: [{ path: "forge.json", content: forgeJson }] }); | |
| 149 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 150 | const row = insertApp(app.db, { repoId: made.value.id, slug: "radar", access: "private" }); | |
| 151 | if (!row.ok) throw new Error(row.error); | |
| 152 | appId = row.value.id; | |
| 153 | absorb(app.db, appId, "admin", "Radar", "/"); | |
| 154 | recordFire(app.db, appId, { tool: "scan", at: Date.now(), ok: true, error: null, durationMs: 3 }); | |
| 155 | adaCookie = await signIn("ada"); | |
| 156 | cyCookie = await signIn("cy"); | |
| 157 | }, 60_000); | |
| 158 | ||
| 159 | afterAll(async () => { | |
| 160 | servers?.stop(); | |
| 161 | app?.close(); | |
| 162 | if (dataDir) await rm(dataDir, { recursive: true, force: true }); | |
| 163 | }); | |
| 164 | ||
| 165 | test("the row has Run now, a POST form", async () => { | |
| 166 | const page = await (await web(adaCookie, "/settings/features/radar")).text(); | |
| 167 | expect(page).toContain('action="/settings/features/radar/run/scan"'); | |
| 168 | expect(page).toContain("Run now"); | |
| 169 | }); | |
| 170 | ||
| 171 | test("a non-admin gets the 404", async () => { | |
| 172 | expect((await web(cyCookie, "/settings/features/radar/run/scan", true)).status).toBe(404); | |
| 173 | }); | |
| 174 | ||
| 175 | test("an admin on an asleep app is sent back with a sentence, and nothing is recorded", async () => { | |
| 176 | const before = lastFires(app.db, appId); | |
| 177 | const res = await web(adaCookie, "/settings/features/radar/run/scan", true); | |
| 178 | expect(res.status).toBe(303); | |
| 179 | expect(res.headers.get("location")).toBe("/settings/features/radar"); | |
| 180 | expect(lastFires(app.db, appId)).toEqual(before); | |
| 181 | }); | |
| 182 | }); |
test/features-tui.test.ts +20
| @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 8 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | import { RUN_REFUSALS } from "../src/apps/feature_run.ts"; | |
| 11 | 12 | import { absorbApp } from "../src/actions/features.ts"; |
| 12 | 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 13 | 14 | import { createApp, type App as Forge } from "../src/app.ts"; |
| @@ -165,6 +166,25 @@ describe("features in the terminal", () => { | ||
| 165 | 166 | t.stop(); |
| 166 | 167 | }); |
| 167 | 168 | |
| 169 | test("an admin presses r on the feed screen to run the scheduled tool; the refusal is the page's sentence", async () => { | |
| 170 | recordFire(forge.db, appId, { tool: "scan", at: Date.now() - 120_000, ok: true, error: null, durationMs: 3 }); | |
| 171 | const t = boot(ada); | |
| 172 | await t.palette("Feature feeds"); | |
| 173 | await t.press("enter"); | |
| 174 | await t.press("enter"); | |
| 175 | await t.press("r"); | |
| 176 | expect(t.text()).toContain(RUN_REFUSALS.asleep); | |
| 177 | t.stop(); | |
| 178 | const other = boot(eve); | |
| 179 | await other.palette("Feature feeds"); | |
| 180 | await other.press("enter"); | |
| 181 | await other.press("enter"); | |
| 182 | await other.press("r"); | |
| 183 | expect(other.text()).not.toContain(RUN_REFUSALS.asleep); | |
| 184 | other.stop(); | |
| 185 | clearFires(forge.db, appId); | |
| 186 | }); | |
| 187 | ||
| 168 | 188 | test("any signed-in reader is offered it, for the apps they may read", async () => { |
| 169 | 189 | const t = boot(eve); |
| 170 | 190 | await t.palette("Feature feeds"); |
The refusal sentences live only in the web page. The terminal needs the same sentences when it runs a tool, so move this map to a shared module (Both doors). Keep the plain words as they are.