Public: anyone can read this; the crew can change it.
Jay Moran's pin demos are on main: src/actions/pin_demos.ts, with launchOf in src/git/builds.ts #9284
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9272-jay-moran-s-pin-demos-are-on-main-src-ac → main 4 commits fork 1 thread · 1 unresolved
5 files
changed +395
−0
src/actions/pin_demos.ts added +253
| @@ -0,0 +1,253 @@ | ||
| 1 | /** | |
| 2 | * A pin's demo: a writer names it, says how it opens, puts it on display | |
| 3 | * pages (the showcase) or takes it off, and gives it a cover. The facts | |
| 4 | * live on the pin's tag (`git/builds.ts`). A demo that opens as a file | |
| 5 | * (`launch` `file`) is the captured HTML page in the data directory | |
| 6 | * (`arcade/carts.ts`); one with no capture yet gets one started, in the | |
| 7 | * background, as the person who asked — the same person a build preview | |
| 8 | * would need. | |
| 9 | * | |
| 10 | * The showcase puts a demo on display pages (`display/items.ts`), for | |
| 11 | * whoever may read its repository. Only a person sets it, never an agent | |
| 12 | * (docs/APPS.md, rule 1). | |
| 13 | */ | |
| 14 | ||
| 15 | import type { App } from "../app.ts"; | |
| 16 | import { captureState, startCapture, type CaptureOptions, type CaptureState } from "../arcade/carts.ts"; | |
| 17 | import { readFileAt } from "../apps/checkout.ts"; | |
| 18 | import { parseManifest } from "../apps/manifest.ts"; | |
| 19 | import type { Repo } from "../core/repos.ts"; | |
| 20 | import type { User } from "../core/users.ts"; | |
| 21 | import { exactRefOid } from "../git/branches.ts"; | |
| 22 | import { | |
| 23 | isDemo, | |
| 24 | launchOf, | |
| 25 | readBuildPin, | |
| 26 | setPinDemo, | |
| 27 | type BuildPin, | |
| 28 | type Demo, | |
| 29 | type Launch, | |
| 30 | type SetDemoError, | |
| 31 | } from "../git/builds.ts"; | |
| 32 | import { isSafeOid } from "../git/guard.ts"; | |
| 33 | import { readerFor } from "../git/read.ts"; | |
| 34 | import { repoDir } from "../git/repos.ts"; | |
| 35 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 36 | import { isMirror } from "../origins/write-through.ts"; | |
| 37 | import { requireWriter, type AccessError } from "./access.ts"; | |
| 38 | ||
| 39 | export type PinDemoError = AccessError | SetDemoError | "mirrored" | "invalid_cover" | "not_a_file"; | |
| 40 | ||
| 41 | export interface DemoInput { | |
| 42 | readonly name: string; | |
| 43 | readonly version: string; | |
| 44 | /** Unset follows the repository's `app.launch`. */ | |
| 45 | readonly launch?: Launch | undefined; | |
| 46 | readonly page: string; | |
| 47 | readonly showcase: boolean; | |
| 48 | } | |
| 49 | ||
| 50 | /** What a commit's `forge.json` says about its demos. */ | |
| 51 | export interface DemoFacts { | |
| 52 | /** `app.pages`: the pages it offers to play and download. */ | |
| 53 | readonly pages: readonly string[]; | |
| 54 | /** `app.launch`, when it names one. */ | |
| 55 | readonly launch?: Launch | undefined; | |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * The pages and the launch a commit's `forge.json` names — or, for a | |
| 60 | * commit pinned before the repository named any pages, what its default | |
| 61 | * branch names now. A page the old build does not serve is simply not | |
| 62 | * found when played. | |
| 63 | */ | |
| 64 | export async function demoFactsAt(app: App, repo: Repo, oid: string): Promise<DemoFacts> { | |
| 65 | if (!isSafeOid(oid)) return { pages: [] }; | |
| 66 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 67 | const read = async (at: string): Promise<DemoFacts> => { | |
| 68 | const [forgeJson, packageJson] = await Promise.all([ | |
| 69 | readFileAt(dir, at, "forge.json"), | |
| 70 | readFileAt(dir, at, "package.json"), | |
| 71 | ]); | |
| 72 | const manifest = parseManifest(forgeJson, packageJson); | |
| 73 | if (!manifest.ok) return { pages: [] }; | |
| 74 | return { | |
| 75 | pages: manifest.value.pages ?? [], | |
| 76 | ...(manifest.value.launch === undefined ? {} : { launch: manifest.value.launch }), | |
| 77 | }; | |
| 78 | }; | |
| 79 | const own = await read(oid); | |
| 80 | if (own.pages.length > 0) return own; | |
| 81 | // Only a commit this repository holds borrows anything. | |
| 82 | const stat = await readerFor(dir).stat(oid); | |
| 83 | if (!stat.ok || stat.value.type !== "commit") return own; | |
| 84 | const tip = await exactRefOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 85 | if (tip === undefined || tip === oid) return own; | |
| 86 | const now = await read(tip); | |
| 87 | return { pages: now.pages, ...((own.launch ?? now.launch) === undefined ? {} : { launch: own.launch ?? now.launch }) }; | |
| 88 | } | |
| 89 | ||
| 90 | /** The facts a pin's demo starts from, before anyone edits them. */ | |
| 91 | export function demoDefaults(repo: Repo, label: string, pages: readonly string[]): Pick<Demo, "name" | "version" | "page"> { | |
| 92 | return { name: repo.name, version: label, page: pages[0] ?? "/" }; | |
| 93 | } | |
| 94 | ||
| 95 | /** A person who writes the repository, never an agent, and not on a mirror: who may change a demo. */ | |
| 96 | export function writerOf(app: App, actor: User | undefined, repo: Repo): Result<User, AccessError | "mirrored"> { | |
| 97 | const writer = requireWriter(app, actor, repo); | |
| 98 | if (!writer.ok) return writer; | |
| 99 | if (writer.value.kind === "agent") return err("forbidden"); | |
| 100 | if (isMirror(app, repo)) return err("mirrored"); | |
| 101 | return writer; | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Set the pin's demo facts, keeping its cover. A demo that opens as a file | |
| 106 | * and has no capture of its page gets one started; `capture` asks for one | |
| 107 | * again after a failure. | |
| 108 | */ | |
| 109 | export async function saveDemo( | |
| 110 | app: App, | |
| 111 | actor: User | undefined, | |
| 112 | repo: Repo, | |
| 113 | label: string, | |
| 114 | input: DemoInput, | |
| 115 | options: CaptureOptions & { readonly capture?: boolean } = {}, | |
| 116 | ): Promise<Result<BuildPin, PinDemoError>> { | |
| 117 | const writer = writerOf(app, actor, repo); | |
| 118 | if (!writer.ok) return writer; | |
| 119 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 120 | const pin = await readBuildPin(dir, label); | |
| 121 | if (pin === undefined) return err("not_found"); | |
| 122 | const demo: Demo = { | |
| 123 | name: input.name.trim(), | |
| 124 | version: input.version.trim(), | |
| 125 | ...(input.launch === undefined ? {} : { launch: input.launch }), | |
| 126 | page: input.page.trim() || "/", | |
| 127 | showcase: input.showcase, | |
| 128 | ...(pin.demo?.cover === undefined ? {} : { cover: pin.demo.cover }), | |
| 129 | }; | |
| 130 | if (!isDemo(demo)) return err("invalid_demo"); | |
| 131 | const set = await setPinDemo(dir, label, demo); | |
| 132 | if (!set.ok) return set; | |
| 133 | app.bus.append({ | |
| 134 | kind: "build.demo", | |
| 135 | repoId: repo.id, | |
| 136 | actorId: writer.value.id, | |
| 137 | payload: { label, oid: pin.oid, demo: demo.name, version: demo.version, launch: demo.launch ?? null, showcase: demo.showcase }, | |
| 138 | }); | |
| 139 | const facts = await demoFactsAt(app, repo, pin.oid); | |
| 140 | if (launchOf(demo, facts.launch, facts.pages) === "file") { | |
| 141 | const state = captureState(app, repo, pin.oid, demo.page).state; | |
| 142 | if (state === "none" || (options.capture === true && state === "failed")) | |
| 143 | void startCapture(app, writer.value, repo, pin.oid, demo.page, options); | |
| 144 | } | |
| 145 | return ok(set.value); | |
| 146 | } | |
| 147 | ||
| 148 | /** Take the pin's demo off display pages; the demo and the pin stay. */ | |
| 149 | export async function unshowcaseDemo( | |
| 150 | app: App, | |
| 151 | actor: User | undefined, | |
| 152 | repo: Repo, | |
| 153 | label: string, | |
| 154 | ): Promise<Result<BuildPin, PinDemoError>> { | |
| 155 | const writer = writerOf(app, actor, repo); | |
| 156 | if (!writer.ok) return writer; | |
| 157 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 158 | const pin = await readBuildPin(dir, label); | |
| 159 | if (pin?.demo === undefined) return err("not_found"); | |
| 160 | if (!pin.demo.showcase) return ok(pin); | |
| 161 | const set = await setPinDemo(dir, label, { ...pin.demo, showcase: false }); | |
| 162 | if (!set.ok) return set; | |
| 163 | app.bus.append({ | |
| 164 | kind: "build.demo", | |
| 165 | repoId: repo.id, | |
| 166 | actorId: writer.value.id, | |
| 167 | payload: { label, oid: pin.oid, demo: pin.demo.name, version: pin.demo.version, showcase: false }, | |
| 168 | }); | |
| 169 | return ok(set.value); | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * Put the pin in the showcase or take it out: the Iterations page's | |
| 174 | * Showcased chip. In: its demo as it stands, else a demo from the pin's | |
| 175 | * defaults, saved with the showcase switch on (`saveDemo`). Out: | |
| 176 | * `unshowcaseDemo`. The demo itself stays either way. | |
| 177 | */ | |
| 178 | export async function setShowcase( | |
| 179 | app: App, | |
| 180 | actor: User | undefined, | |
| 181 | repo: Repo, | |
| 182 | label: string, | |
| 183 | on: boolean, | |
| 184 | ): Promise<Result<BuildPin, PinDemoError>> { | |
| 185 | if (!on) return unshowcaseDemo(app, actor, repo, label); | |
| 186 | const writer = writerOf(app, actor, repo); | |
| 187 | if (!writer.ok) return writer; | |
| 188 | const pin = await readBuildPin(repoDir(app.reposRoot, repo.ownerName, repo.name), label); | |
| 189 | if (pin === undefined) return err("not_found"); | |
| 190 | if (pin.demo?.showcase === true) return ok(pin); | |
| 191 | const facts = pin.demo === undefined ? await demoFactsAt(app, repo, pin.oid) : undefined; | |
| 192 | const base = pin.demo ?? { ...demoDefaults(repo, label, facts?.pages ?? []), showcase: false }; | |
| 193 | return saveDemo(app, writer.value, repo, label, { | |
| 194 | name: base.name, | |
| 195 | version: base.version, | |
| 196 | ...(pin.demo?.launch === undefined ? {} : { launch: pin.demo.launch }), | |
| 197 | page: base.page, | |
| 198 | showcase: true, | |
| 199 | }); | |
| 200 | } | |
| 201 | ||
| 202 | /** | |
| 203 | * Clear the pin's demo: its tag is written again without the demo | |
| 204 | * trailers. The pin, its commit and its screenshots stay. | |
| 205 | */ | |
| 206 | export async function deleteDemo( | |
| 207 | app: App, | |
| 208 | actor: User | undefined, | |
| 209 | repo: Repo, | |
| 210 | label: string, | |
| 211 | ): Promise<Result<BuildPin, PinDemoError>> { | |
| 212 | const writer = writerOf(app, actor, repo); | |
| 213 | if (!writer.ok) return writer; | |
| 214 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 215 | const pin = await readBuildPin(dir, label); | |
| 216 | if (pin?.demo === undefined) return err("not_found"); | |
| 217 | const set = await setPinDemo(dir, label, undefined); | |
| 218 | if (!set.ok) return set; | |
| 219 | app.bus.append({ | |
| 220 | kind: "build.demo", | |
| 221 | repoId: repo.id, | |
| 222 | actorId: writer.value.id, | |
| 223 | payload: { label, oid: pin.oid, demo: null, previous: pin.demo.name, version: pin.demo.version }, | |
| 224 | }); | |
| 225 | return ok(set.value); | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * Build a pin's page and keep it: its capture, started in the background | |
| 230 | * as the writer who asked (`startCapture`), when the pin opens as a file | |
| 231 | * and its page is not captured or its capture failed. What the Iterations | |
| 232 | * page's Build and play does; the page then waits on `captureState`. | |
| 233 | */ | |
| 234 | export async function captureBuild( | |
| 235 | app: App, | |
| 236 | actor: User | undefined, | |
| 237 | repo: Repo, | |
| 238 | label: string, | |
| 239 | options: CaptureOptions = {}, | |
| 240 | ): Promise<Result<CaptureState["state"], PinDemoError>> { | |
| 241 | const writer = writerOf(app, actor, repo); | |
| 242 | if (!writer.ok) return writer; | |
| 243 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 244 | const pin = await readBuildPin(dir, label); | |
| 245 | if (pin === undefined) return err("not_found"); | |
| 246 | const facts = await demoFactsAt(app, repo, pin.oid); | |
| 247 | if (launchOf(pin.demo, facts.launch, facts.pages) !== "file") return err("not_a_file"); | |
| 248 | const page = pin.demo?.page ?? demoDefaults(repo, label, facts.pages).page; | |
| 249 | const state = captureState(app, repo, pin.oid, page).state; | |
| 250 | if (state === "done" || state === "capturing") return ok(state); | |
| 251 | void startCapture(app, writer.value, repo, pin.oid, page, options); | |
| 252 | return ok("capturing"); | |
| 253 | } |
src/apps/manifest.ts +36
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { isIP } from "node:net"; |
| 14 | import { isLaunch, LAUNCHES, type Launch } from "../git/builds.ts"; | |
| 14 | 15 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | 16 | import { parseService, type ServiceSpec } from "./service.ts"; |
| 16 | 17 | import type { DeclaredLimits } from "./contain.ts"; |
| @@ -39,6 +40,17 @@ export interface AppManifest { | ||
| 39 | 40 | * before this field existed. |
| 40 | 41 | */ |
| 41 | 42 | readonly looks: readonly string[]; |
| 43 | /** | |
| 44 | * `app.pages`: the pages a person plays or downloads, each a path the app | |
| 45 | * serves as one self-contained page (`"/"`, `"/spicy"`). Empty or unset | |
| 46 | * when the manifest names none. | |
| 47 | */ | |
| 48 | readonly pages?: readonly string[] | undefined; | |
| 49 | /** | |
| 50 | * `app.launch`: how this repository's demos open unless a pin says | |
| 51 | * otherwise (`launchOf`). Unset follows `launchOf`. | |
| 52 | */ | |
| 53 | readonly launch?: Launch | undefined; | |
| 42 | 54 | /** |
| 43 | 55 | * `app.looksAsOwner`: the pages of `looks` whose phone and desktop |
| 44 | 56 | * pictures are taken signed in as the owner, even of a public app — |
| @@ -149,6 +161,11 @@ export function parseManifest( | ||
| 149 | 161 | ); |
| 150 | 162 | const looks = looksField(app); |
| 151 | 163 | if (!looks.ok) return looks; |
| 164 | const pages = pagesField(app); | |
| 165 | if (!pages.ok) return pages; | |
| 166 | const launch = app["launch"]; | |
| 167 | if (launch !== undefined && !isLaunch(launch)) | |
| 168 | return invalid(`forge.json: app.launch must be one of ${LAUNCHES.map((l) => `"${l}"`).join(", ")}`); | |
| 152 | 169 | const looksAsOwner = looksAsOwnerField(app, looks.value); |
| 153 | 170 | if (!looksAsOwner.ok) return looksAsOwner; |
| 154 | 171 | const egress = egressField(parsed.value); |
| @@ -184,6 +201,8 @@ export function parseManifest( | ||
| 184 | 201 | tty: tty.value || null, |
| 185 | 202 | looks: looks.value, |
| 186 | 203 | looksAsOwner: looksAsOwner.value, |
| 204 | pages: pages.value, | |
| 205 | ...(launch === undefined ? {} : { launch }), | |
| 187 | 206 | service, |
| 188 | 207 | egress: egress.value, |
| 189 | 208 | limits: limits.value, |
| @@ -268,6 +287,23 @@ function limitsField( | ||
| 268 | 287 | return ok(out); |
| 269 | 288 | } |
| 270 | 289 | |
| 290 | /** How many pages a manifest may offer to play. */ | |
| 291 | export const PAGES_MAX = 20; | |
| 292 | ||
| 293 | function pagesField(app: Json): Result<readonly string[], ManifestError> { | |
| 294 | const value = app["pages"]; | |
| 295 | if (value === undefined) return ok([]); | |
| 296 | if (!Array.isArray(value) || value.length > PAGES_MAX) | |
| 297 | return invalid(`forge.json: app.pages must be an array of at most ${PAGES_MAX} paths`); | |
| 298 | const paths: string[] = []; | |
| 299 | for (const item of value) { | |
| 300 | if (typeof item !== "string" || !/^\/[A-Za-z0-9._-]*(\/[A-Za-z0-9._-]+)*$/.test(item) || item.length > 120 || item.includes("..")) | |
| 301 | return invalid('forge.json: app.pages entries are paths like "/" or "/spicy": letters, digits, dot, dash, underscore'); | |
| 302 | if (!paths.includes(item)) paths.push(item); | |
| 303 | } | |
| 304 | return ok(paths); | |
| 305 | } | |
| 306 | ||
| 271 | 307 | /** |
| 272 | 308 | * `app.looks`: the pages worth a picture. Absent is `["/"]`, exactly as |
| 273 | 309 | * before this field existed. Validated like any other boundary input — a |
src/core/event_audience.ts +1
| @@ -135,6 +135,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 135 | 135 | "build.pinned", |
| 136 | 136 | "build.deleted", |
| 137 | 137 | "build.noted", |
| 138 | "build.demo", | |
| 138 | 139 | // A pin's screenshots are read by its repository's readers (`actions/build_shots.ts`). |
| 139 | 140 | "build.shots", |
| 140 | 141 | "invite.used", |
src/git/builds.ts +13
| @@ -66,6 +66,19 @@ export interface Demo { | ||
| 66 | 66 | readonly cover?: string | undefined; |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | /** | |
| 70 | * How a demo opens: its pin's `Launch:`, else the repository's | |
| 71 | * `app.launch`, else `file` when the app names pages to play and `env` | |
| 72 | * when it names none. | |
| 73 | */ | |
| 74 | export function launchOf( | |
| 75 | demo: Pick<Demo, "launch"> | undefined, | |
| 76 | appLaunch: Launch | undefined, | |
| 77 | pages: readonly string[], | |
| 78 | ): Launch { | |
| 79 | return demo?.launch ?? appLaunch ?? (pages.length > 0 ? "file" : "env"); | |
| 80 | } | |
| 81 | ||
| 69 | 82 | export const COVERS_PREFIX = "refs/covers/"; |
| 70 | 83 | |
| 71 | 84 | /** A demo fact: one plain line, at most `max` characters. */ |
test/pin-demos.test.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * A pin's demo (src/actions/pin_demos.ts): defaults from a label and pages, | |
| 3 | * the facts a pinned commit's forge.json names, and who may save one. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { demoDefaults, demoFactsAt, saveDemo } from "../src/actions/pin_demos.ts"; | |
| 11 | import { pinBuild } from "../src/actions/builds.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { getRepo } from "../src/core/repos.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { launchOf, readBuildPin } from "../src/git/builds.ts"; | |
| 16 | import { repoDir } from "../src/git/repos.ts"; | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let alice: User; | |
| 21 | let bob: User; | |
| 22 | let oid: string; | |
| 23 | const fresh = () => getRepo(app.db, "alice", "game")!; | |
| 24 | const input = { name: "Game", version: "v1", page: "/", showcase: false, launch: "env" as const }; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-pin-demos-")); | |
| 28 | app = createApp({ dataDir, port: 0 }); | |
| 29 | const users: User[] = []; | |
| 30 | for (const name of ["alice", "bob"]) { | |
| 31 | const made = await createUser(app.db, { | |
| 32 | origin: "seed", | |
| 33 | trusted: true, | |
| 34 | username: name, | |
| 35 | email: `${name}@forge.local`, | |
| 36 | password: "a-good-passphrase", | |
| 37 | adminIfFirst: name === "alice", | |
| 38 | }); | |
| 39 | if (!made.ok) throw new Error(made.error); | |
| 40 | users.push(made.value); | |
| 41 | } | |
| 42 | [alice, bob] = users as [User, User]; | |
| 43 | const made = await createRepo(app, alice, { name: "game", visibility: "public" }); | |
| 44 | if (!made.ok) throw new Error(String(made.error)); | |
| 45 | const forgeJson = JSON.stringify({ app: { run: "bun run x.ts", pages: ["/", "/spicy"], launch: "file" } }); | |
| 46 | const c = await commitToBranch(app, alice, fresh(), { | |
| 47 | branch: "main", | |
| 48 | message: "one", | |
| 49 | files: [{ path: "forge.json", content: forgeJson }, { path: "x.ts", content: "\n" }], | |
| 50 | }); | |
| 51 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 52 | oid = c.value.commitOid; | |
| 53 | const pinned = await pinBuild(app, alice, fresh(), { oid, label: "v1" }); | |
| 54 | if (!pinned.ok) throw new Error(String(pinned.error)); | |
| 55 | }); | |
| 56 | ||
| 57 | afterAll(async () => { | |
| 58 | app.close(); | |
| 59 | await rm(dataDir, { recursive: true, force: true }); | |
| 60 | }); | |
| 61 | ||
| 62 | test("demoDefaults names a demo from a label and the first page", () => { | |
| 63 | expect(demoDefaults(fresh(), "v1", ["/spicy", "/"])).toEqual({ name: "game", version: "v1", page: "/spicy" }); | |
| 64 | expect(demoDefaults(fresh(), "v2", []).page).toBe("/"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("demoFactsAt reads a pinned commit's pages and launch", async () => { | |
| 68 | expect(await demoFactsAt(app, fresh(), oid)).toEqual({ pages: ["/", "/spicy"], launch: "file" }); | |
| 69 | expect(await demoFactsAt(app, fresh(), "not-an-oid")).toEqual({ pages: [] }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("launchOf: the pin, then the app, then pages decide", () => { | |
| 73 | expect(launchOf({ launch: "shots" }, "file", ["/"])).toBe("shots"); | |
| 74 | expect(launchOf(undefined, "env", ["/"])).toBe("env"); | |
| 75 | expect(launchOf(undefined, undefined, ["/"])).toBe("file"); | |
| 76 | expect(launchOf(undefined, undefined, [])).toBe("env"); | |
| 77 | }); | |
| 78 | ||
| 79 | test("saveDemo by a non-writer is refused with the access error", async () => { | |
| 80 | const refused = await saveDemo(app, bob, fresh(), "v1", input); | |
| 81 | expect(refused).toEqual({ ok: false, error: "forbidden" }); | |
| 82 | const anon = await saveDemo(app, undefined, fresh(), "v1", input); | |
| 83 | expect(anon).toEqual({ ok: false, error: "unauthorized" }); | |
| 84 | expect((await readBuildPin(repoDir(app.reposRoot, "alice", "game"), "v1"))?.demo).toBeUndefined(); | |
| 85 | }); | |
| 86 | ||
| 87 | test("saveDemo by the writer keeps the demo on the pin", async () => { | |
| 88 | const saved = await saveDemo(app, alice, fresh(), "v1", input); | |
| 89 | expect(saved.ok).toBe(true); | |
| 90 | const pin = await readBuildPin(repoDir(app.reposRoot, "alice", "game"), "v1"); | |
| 91 | expect(pin?.demo).toMatchObject({ name: "Game", version: "v1", launch: "env", page: "/" }); | |
| 92 | }); |
new above
offline
This inserts PAGES_MAX and pagesField between looksField's doc comment (just above) and looksField itself, so that comment now documents PAGES_MAX. Move this block above the looks comment or below looksField.