Public: anyone can read this; the crew can change it.
The browser tab still says "Issues" and "Pull requests" on every studio page #311
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/262-the-browser-tab-still-says-issues-and-pu → main 2 commits fork
8 files
changed +216
−52
src/http/server.ts +2
| @@ -75,6 +75,7 @@ import { | ||
| 75 | 75 | issueThreadFragment, |
| 76 | 76 | newIssuePage, |
| 77 | 77 | newIssueSubmit, |
| 78 | newTellPage, | |
| 78 | 79 | tellPage, |
| 79 | 80 | } from "../web/pages/issues.ts"; |
| 80 | 81 | import { globalMapPage, mapPage } from "../web/pages/map.ts"; |
| @@ -549,6 +550,7 @@ export function buildRouter(): Router<Handler> { | ||
| 549 | 550 | // apply). The literal /tells is registered before /tells/:number for the |
| 550 | 551 | // same reason /issues/new is. |
| 551 | 552 | router.add("GET", "/:owner/:name/tells", tellsList); |
| 553 | router.add("GET", "/:owner/:name/tells/new", newTellPage); | |
| 552 | 554 | router.add("GET", "/:owner/:name/tells/:number", tellPage); |
| 553 | 555 | router.add("GET", "/:owner/:name/issues/new", newIssuePage); |
| 554 | 556 | router.add("POST", "/:owner/:name/issues/new", newIssueSubmit); |
src/web/pages/issues.ts +62 −33
| @@ -63,7 +63,12 @@ import { | ||
| 63 | 63 | } from "./thread.ts"; |
| 64 | 64 | import { crewActivityCard } from "./runs.ts"; |
| 65 | 65 | import { studioTells } from "../studio/lists.ts"; |
| 66 | import { studioRecord } from "../studio/nouns.ts"; | |
| 66 | import { | |
| 67 | PAGE_TITLES, | |
| 68 | pageTitle, | |
| 69 | studioRecord, | |
| 70 | tellsHref, | |
| 71 | } from "../studio/nouns.ts"; | |
| 67 | 72 | import { studioTell } from "../studio/tell.ts"; |
| 68 | 73 | |
| 69 | 74 | const PER_PAGE = 25; |
| @@ -361,7 +366,7 @@ export async function issuesList(ctx: Ctx): Promise<Response> { | ||
| 361 | 366 | |
| 362 | 367 | return page({ |
| 363 | 368 | ctx, |
| 364 | title: `Issues · ${repo.name}`, | |
| 369 | title: pageTitle("tells", repo, false), | |
| 365 | 370 | crumbs: scope.crumbs, |
| 366 | 371 | palette: scope.palette, |
| 367 | 372 | body, |
| @@ -372,9 +377,49 @@ export async function issuesList(ctx: Ctx): Promise<Response> { | ||
| 372 | 377 | |
| 373 | 378 | // ---- compose ---- |
| 374 | 379 | |
| 380 | /** | |
| 381 | * The compose page, in the face the viewer has: the studio's own noun for | |
| 382 | * it (`PAGE_TITLES.compose`) on the tab, the heading and the button, and | |
| 383 | * Cancel back to the list that face calls home. The form posts to | |
| 384 | * `/issues/new` in both faces — the one door that changes something. | |
| 385 | */ | |
| 386 | function composePage( | |
| 387 | ctx: Ctx, | |
| 388 | repo: Repo, | |
| 389 | values: { title: string; body: string }, | |
| 390 | error?: string, | |
| 391 | ): Response { | |
| 392 | const studio = studioRecord(ctx, repo) !== undefined; | |
| 393 | const heading = PAGE_TITLES.compose[studio ? "studio" : "dev"]; | |
| 394 | const base = repoBase(repo); | |
| 395 | return page( | |
| 396 | { | |
| 397 | ctx, | |
| 398 | title: pageTitle("compose", repo, studio), | |
| 399 | crumbs: crumbsFor(repo), | |
| 400 | palette: repoPalette(ctx, repo), | |
| 401 | body: html`${repoHeader(ctx, repo, "issues", { | |
| 402 | compact: true, | |
| 403 | ownTitle: true, | |
| 404 | })} | |
| 405 | <h1 class="mt">${heading}</h1> | |
| 406 | ${newIssueForm( | |
| 407 | repo, | |
| 408 | values, | |
| 409 | studio ? "Tell" : "Create issue", | |
| 410 | studio ? tellsHref(base) : `${base}/issues`, | |
| 411 | error, | |
| 412 | )}`, | |
| 413 | }, | |
| 414 | error === undefined ? 200 : 422, | |
| 415 | ); | |
| 416 | } | |
| 417 | ||
| 375 | 418 | function newIssueForm( |
| 376 | 419 | repo: Repo, |
| 377 | 420 | values: { title: string; body: string }, |
| 421 | submit: string, | |
| 422 | cancelHref: string, | |
| 378 | 423 | error?: string, |
| 379 | 424 | ): Html { |
| 380 | 425 | return html`<div class="narrow-wide mt"> |
| @@ -397,11 +442,8 @@ function newIssueForm( | ||
| 397 | 442 | hint: "Markdown is supported.", |
| 398 | 443 | })} |
| 399 | 444 | <div class="form-actions"> |
| 400 | ${btn("Create issue", { variant: "primary", icon: "issue" })} | |
| 401 | ${btn("Cancel", { | |
| 402 | href: `${repoBase(repo)}/issues`, | |
| 403 | variant: "ghost", | |
| 404 | })} | |
| 445 | ${btn(submit, { variant: "primary", icon: "issue" })} | |
| 446 | ${btn("Cancel", { href: cancelHref, variant: "ghost" })} | |
| 405 | 447 | </div> |
| 406 | 448 | </form> |
| 407 | 449 | </div>`; |
| @@ -412,18 +454,18 @@ export function newIssuePage(ctx: Ctx): Response { | ||
| 412 | 454 | const repo = loadRepo(ctx); |
| 413 | 455 | if (!repo) return notFoundPage(ctx); |
| 414 | 456 | if (!ctx.auth.user) return signInFirst(ctx); |
| 415 | return page({ | |
| 416 | ctx, | |
| 417 | title: `New issue · ${repo.name}`, | |
| 418 | crumbs: crumbsFor(repo), | |
| 419 | palette: repoPalette(ctx, repo), | |
| 420 | body: html`${repoHeader(ctx, repo, "issues", { | |
| 421 | compact: true, | |
| 422 | ownTitle: true, | |
| 423 | })} | |
| 424 | <h1 class="mt">New issue</h1> | |
| 425 | ${newIssueForm(repo, { title: "", body: "" })}`, | |
| 426 | }); | |
| 457 | return composePage(ctx, repo, { title: "", body: "" }); | |
| 458 | } | |
| 459 | ||
| 460 | // GET /:owner/:name/tells/new — the studio's own door onto the compose page | |
| 461 | // `/issues/new` reaches, redirecting there when the studio does not apply, | |
| 462 | // the same rule `tellPage` follows for one tell. | |
| 463 | export function newTellPage(ctx: Ctx): Response { | |
| 464 | const repo = loadRepo(ctx); | |
| 465 | if (!repo) return notFoundPage(ctx); | |
| 466 | if (studioRecord(ctx, repo) === undefined) | |
| 467 | return redirect(`${repoBase(repo)}/issues/new${ctx.url.search}`); | |
| 468 | return newIssuePage(ctx); | |
| 427 | 469 | } |
| 428 | 470 | |
| 429 | 471 | // POST /:owner/:name/issues/new |
| @@ -449,20 +491,7 @@ export async function newIssueSubmit(ctx: Ctx): Promise<Response> { | ||
| 449 | 491 | : created.error === "body_too_long" |
| 450 | 492 | ? "That description is too long (64 KB at most)." |
| 451 | 493 | : "You cannot open an issue here."); |
| 452 | return page( | |
| 453 | { | |
| 454 | ctx, | |
| 455 | title: `New issue · ${repo.name}`, | |
| 456 | crumbs: crumbsFor(repo), | |
| 457 | body: html`${repoHeader(ctx, repo, "issues", { | |
| 458 | compact: true, | |
| 459 | ownTitle: true, | |
| 460 | })} | |
| 461 | <h1 class="mt">New issue</h1> | |
| 462 | ${newIssueForm(repo, values, message)}`, | |
| 463 | }, | |
| 464 | 422, | |
| 465 | ); | |
| 494 | return composePage(ctx, repo, values, message); | |
| 466 | 495 | } |
| 467 | 496 | return flashTo(ctx, `${repoBase(repo)}/issues/${created.value.number}`, { |
| 468 | 497 | message: `Issue #${created.value.number} opened.`, |
src/web/pages/pulls.ts +2 −2
| @@ -53,7 +53,7 @@ import { | ||
| 53 | 53 | import { refSet } from "./refs.ts"; |
| 54 | 54 | import { threadRow } from "./issues.ts"; |
| 55 | 55 | import { studioChanges } from "../studio/lists.ts"; |
| 56 | import { studioRecord } from "../studio/nouns.ts"; | |
| 56 | import { pageTitle, studioRecord } from "../studio/nouns.ts"; | |
| 57 | 57 | import { originGlyph } from "./thread.ts"; |
| 58 | 58 | |
| 59 | 59 | const PER_PAGE = 25; |
| @@ -276,7 +276,7 @@ export async function pullsList(ctx: Ctx): Promise<Response> { | ||
| 276 | 276 | |
| 277 | 277 | return page({ |
| 278 | 278 | ctx, |
| 279 | title: `Pull requests · ${repo.name}`, | |
| 279 | title: pageTitle("changes", repo, false), | |
| 280 | 280 | crumbs: crumbsFor(repo), |
| 281 | 281 | palette: repoPalette(ctx, repo), |
| 282 | 282 | body, |
src/web/pages/runs.ts +2 −2
| @@ -37,7 +37,7 @@ import { html, when, type Html } from "../html.ts"; | ||
| 37 | 37 | import { flashTo, page, redirect } from "../layout.ts"; |
| 38 | 38 | import { liveClientJs, liveMount } from "../live.ts"; |
| 39 | 39 | import { studioRuns } from "../studio/lists.ts"; |
| 40 | import { studioRecord } from "../studio/nouns.ts"; | |
| 40 | import { pageTitle, studioRecord } from "../studio/nouns.ts"; | |
| 41 | 41 | import { studioRun } from "../studio/run.ts"; |
| 42 | 42 | import { |
| 43 | 43 | btn, |
| @@ -332,7 +332,7 @@ export async function runsList(ctx: Ctx): Promise<Response> { | ||
| 332 | 332 | |
| 333 | 333 | return page({ |
| 334 | 334 | ctx, |
| 335 | title: `Runs · ${repo.name}`, | |
| 335 | title: pageTitle("runs", repo, false), | |
| 336 | 336 | crumbs: crumbsFor(repo), |
| 337 | 337 | palette: repoPalette(ctx, repo), |
| 338 | 338 | body, |
src/web/studio/crew.ts +3 −2
| @@ -26,6 +26,7 @@ import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; | ||
| 26 | 26 | import { html, raw, type Html } from "../html.ts"; |
| 27 | 27 | import { icon } from "../icons.ts"; |
| 28 | 28 | import { page } from "../layout.ts"; |
| 29 | import { pageTitle } from "./nouns.ts"; | |
| 29 | 30 | import { |
| 30 | 31 | btn, |
| 31 | 32 | card, |
| @@ -224,7 +225,7 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html | ||
| 224 | 225 | name: "ceiling", |
| 225 | 226 | value: String(repo.autonomyCeilingUsd), |
| 226 | 227 | type: "number", |
| 227 | hint: "Dollars of agent spend on this repository per UTC day. At the ceiling the crew stops picking until midnight; nothing in flight is cut.", | |
| 228 | hint: "Dollars of agent spend on this app per UTC day. At the ceiling the crew stops picking until midnight; nothing in flight is cut.", | |
| 228 | 229 | })} |
| 229 | 230 | ${field({ |
| 230 | 231 | label: "Slots", |
| @@ -271,7 +272,7 @@ export function studioCrewSettings( | ||
| 271 | 272 | </div>`; |
| 272 | 273 | return page({ |
| 273 | 274 | ctx, |
| 274 | title: `Settings · ${repo.name}`, | |
| 275 | title: pageTitle("settings", repo, true), | |
| 275 | 276 | crumbs: crumbsFor(repo), |
| 276 | 277 | palette: repoPalette(ctx, repo), |
| 277 | 278 | body, |
src/web/studio/lists.ts +9 −4
| @@ -37,7 +37,12 @@ import { | ||
| 37 | 37 | time, |
| 38 | 38 | } from "../ui.ts"; |
| 39 | 39 | import { actorsFor, issuesFor, runTitleFrom } from "../pages/runs.ts"; |
| 40 | import { changeHref, studioRecord, tellHref } from "./nouns.ts"; | |
| 40 | import { | |
| 41 | changeHref, | |
| 42 | pageTitle, | |
| 43 | studioRecord, | |
| 44 | tellHref, | |
| 45 | } from "./nouns.ts"; | |
| 41 | 46 | import { tellWord } from "./tell.ts"; |
| 42 | 47 | import { |
| 43 | 48 | pullWord, |
| @@ -201,7 +206,7 @@ export async function studioTells( | ||
| 201 | 206 | |
| 202 | 207 | return page({ |
| 203 | 208 | ctx, |
| 204 | title: `Everything told · ${repo.name}`, | |
| 209 | title: pageTitle("tells", repo, true), | |
| 205 | 210 | crumbs: crumbsFor(repo), |
| 206 | 211 | palette: repoPalette(ctx, repo), |
| 207 | 212 | body, |
| @@ -289,7 +294,7 @@ export async function studioChanges( | ||
| 289 | 294 | |
| 290 | 295 | return page({ |
| 291 | 296 | ctx, |
| 292 | title: `Every change · ${repo.name}`, | |
| 297 | title: pageTitle("changes", repo, true), | |
| 293 | 298 | crumbs: crumbsFor(repo), |
| 294 | 299 | palette: repoPalette(ctx, repo), |
| 295 | 300 | body, |
| @@ -403,7 +408,7 @@ export async function studioRuns( | ||
| 403 | 408 | |
| 404 | 409 | return page({ |
| 405 | 410 | ctx, |
| 406 | title: `Every run · ${repo.name}`, | |
| 411 | title: pageTitle("runs", repo, true), | |
| 407 | 412 | crumbs: crumbsFor(repo), |
| 408 | 413 | palette: repoPalette(ctx, repo), |
| 409 | 414 | body, |
src/web/studio/nouns.ts +29
| @@ -47,3 +47,32 @@ export function studioRecord(ctx: Ctx, repo: Repo): AppRecord | undefined { | ||
| 47 | 47 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; |
| 48 | 48 | return studio ? getAppByRepo(ctx.app.db, repo.id) : undefined; |
| 49 | 49 | } |
| 50 | ||
| 51 | /** | |
| 52 | * What each page a studio viewer can reach calls itself in the browser tab | |
| 53 | * — the bookmark, the history entry, the first thing a screen reader says | |
| 54 | * on arrival — in both faces, decided here beside the addresses so a page | |
| 55 | * never carries a second literal of its own. `studio` is the word when | |
| 56 | * `studioRecord` gives the page a studio; `dev` is today's word for dev and | |
| 57 | * for any repository that is not an app. A single tell, change or run | |
| 58 | * titles itself by its own title, and the app page by the app's name, so | |
| 59 | * none of those is here. | |
| 60 | */ | |
| 61 | export const PAGE_TITLES = { | |
| 62 | tells: { studio: "Everything told", dev: "Issues" }, | |
| 63 | changes: { studio: "Every change", dev: "Pull requests" }, | |
| 64 | runs: { studio: "Every run", dev: "Runs" }, | |
| 65 | compose: { studio: "Tell the crew", dev: "New issue" }, | |
| 66 | settings: { studio: "Settings", dev: "Settings" }, | |
| 67 | } as const; | |
| 68 | ||
| 69 | export type TitledPage = keyof typeof PAGE_TITLES; | |
| 70 | ||
| 71 | /** A page's `<title>` noun and the repository it is about, in the face | |
| 72 | * the viewer has: `Everything told · notes`, or `Issues · notes`. */ | |
| 73 | export const pageTitle = ( | |
| 74 | which: TitledPage, | |
| 75 | repo: Repo, | |
| 76 | studio: boolean, | |
| 77 | ): string => | |
| 78 | `${PAGE_TITLES[which][studio ? "studio" : "dev"]} · ${repo.name}`; |
test/studio-words.test.ts +107 −9
| @@ -11,7 +11,10 @@ | ||
| 11 | 11 | * Nothing else in this file changes — the same forbidden-vocabulary, |
| 12 | 12 | * chrome, header and size assertions walk it, and so does the `?mode=dev` |
| 13 | 13 | * test below, which is why a row carries the dev words the same path still |
| 14 | * says in dev's own face: the table is the one place a page is added, and | |
| 14 | * says in dev's own face. A row also names the `<title>` it answers with in | |
| 15 | * each face — the browser tab, the bookmark, the first thing a screen reader | |
| 16 | * announces (#262) — since `<head>` is outside `<main>` and nothing else | |
| 17 | * here would read it. The table is the one place a page is added, and | |
| 15 | 18 | * the proof it is not vacuous comes with it. Whatever a row needs to be |
| 16 | 19 | * worth fetching — a second tell, a second change, a run that stopped, a |
| 17 | 20 | * run still going — the one `beforeAll` seeds; there is no second seeding |
| @@ -132,6 +135,11 @@ const pageContent = (html: string): string => | ||
| 132 | 135 | // and it rides on things dev and studio share alike (a timeline dot). |
| 133 | 136 | .replace(/<svg[\s\S]*?<\/svg>/g, ""); |
| 134 | 137 | |
| 138 | /** The page's own title — `page()`'s `<title>`, without the sitewide | |
| 139 | * ` · forge` every page ends with. */ | |
| 140 | const titleOf = (html: string): string => | |
| 141 | (html.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? "").replace(/ · forge$/, ""); | |
| 142 | ||
| 135 | 143 | /** Generous next to a real page (tens of KB); nowhere near the 2.5 MB one |
| 136 | 144 | * file diff cost the studio pull page before it had its own render. */ |
| 137 | 145 | const MAX_BODY_BYTES = 300_000; |
| @@ -155,6 +163,10 @@ interface StudioPage { | ||
| 155 | 163 | /** Dev's own words the same path still says at `?mode=dev`, when dev has |
| 156 | 164 | * a page of its own there. */ |
| 157 | 165 | readonly dev?: readonly string[]; |
| 166 | /** The whole `<title>` the studio's page answers with, ` · forge` aside. */ | |
| 167 | readonly title?: string; | |
| 168 | /** The whole `<title>` dev's own page answers with at `?mode=dev`. */ | |
| 169 | readonly devTitle?: string; | |
| 158 | 170 | } |
| 159 | 171 | |
| 160 | 172 | /** `?mode=dev` added to a path that may already carry a query. */ |
| @@ -491,6 +503,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 491 | 503 | // both stay the picture card's and the word badge's alone (#172). |
| 492 | 504 | word: "app", |
| 493 | 505 | path: repoPath, |
| 506 | title: "vocab", | |
| 494 | 507 | shows: [`href="#i-pull"/></svg>Changes`], |
| 495 | 508 | hides: [`href="#i-eye"/></svg>Look`, `>Look<`], |
| 496 | 509 | }, |
| @@ -501,6 +514,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 501 | 514 | // dev's own noun on the studio's own page. |
| 502 | 515 | shows: ["Close tell"], |
| 503 | 516 | dev: ["<h2>Labels</h2>", "Close issue"], |
| 517 | devTitle: `${tellTitle} · vocab`, | |
| 504 | 518 | }, |
| 505 | 519 | // The studio's own nouns for the same two pages (#158): one handler |
| 506 | 520 | // each, so what is walked here is the very page above under the |
| @@ -511,8 +525,14 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 511 | 525 | word: "tell (the studio's noun)", |
| 512 | 526 | path: `${repoPath}/tells/${tellNumber}`, |
| 513 | 527 | shows: [tellTitle, "Close tell"], |
| 528 | title: `${tellTitle} · vocab`, | |
| 529 | }, | |
| 530 | { | |
| 531 | word: "working", | |
| 532 | path: `${repoPath}/runs/${runId}`, | |
| 533 | title: `scout on #${tellNumber} ${tellTitle} · vocab`, | |
| 534 | devTitle: `scout on #${tellNumber} ${tellTitle} · vocab`, | |
| 514 | 535 | }, |
| 515 | { word: "working", path: `${repoPath}/runs/${runId}` }, | |
| 516 | 536 | { |
| 517 | 537 | word: "look", |
| 518 | 538 | path: `${repoPath}/pulls/${openPullNumber}`, |
| @@ -520,11 +540,13 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 520 | 540 | // request" here, dev's own noun on the studio's own page. |
| 521 | 541 | shows: ["Close change"], |
| 522 | 542 | dev: ["diff-table", openBranch, "Close pull request"], |
| 543 | devTitle: `${openPullTitle} · vocab`, | |
| 523 | 544 | }, |
| 524 | 545 | { |
| 525 | 546 | word: "look (the studio's noun)", |
| 526 | 547 | path: `${repoPath}/changes/${openPullNumber}`, |
| 527 | 548 | shows: [openPullTitle, "Close change"], |
| 549 | title: `${openPullTitle} · vocab`, | |
| 528 | 550 | }, |
| 529 | 551 | { |
| 530 | 552 | word: "shipped", |
| @@ -565,6 +587,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 565 | 587 | path: `${repoPath}/issues`, |
| 566 | 588 | shows: [untouchedTitle, tellTitle], |
| 567 | 589 | dev: [`>#${tellNumber} opened`], |
| 590 | devTitle: "Issues · vocab", | |
| 568 | 591 | }, |
| 569 | 592 | { |
| 570 | 593 | word: "tells (told)", |
| @@ -584,17 +607,37 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 584 | 607 | word: "tells (the studio's noun)", |
| 585 | 608 | path: `${repoPath}/tells`, |
| 586 | 609 | shows: [untouchedTitle, tellTitle], |
| 610 | title: "Everything told · vocab", | |
| 611 | }, | |
| 612 | { | |
| 613 | // #262: the one compose page, under the studio's own noun and dev's | |
| 614 | // own address alike — the tab, the heading and the button all say | |
| 615 | // the studio's word, and the form still posts where it always has. | |
| 616 | word: "tell the crew (the studio's noun)", | |
| 617 | path: `${repoPath}/tells/new`, | |
| 618 | shows: [`action="${repoPath}/issues/new"`, "<h1 class=\"mt\">Tell the crew</h1>"], | |
| 619 | title: "Tell the crew · vocab", | |
| 620 | }, | |
| 621 | { | |
| 622 | word: "tell the crew", | |
| 623 | path: `${repoPath}/issues/new`, | |
| 624 | shows: [`action="${repoPath}/issues/new"`, "<h1 class=\"mt\">Tell the crew</h1>"], | |
| 625 | title: "Tell the crew · vocab", | |
| 626 | dev: ["New issue", "Create issue"], | |
| 627 | devTitle: "New issue · vocab", | |
| 587 | 628 | }, |
| 588 | 629 | { |
| 589 | 630 | word: "changes (the studio's noun)", |
| 590 | 631 | path: `${repoPath}/changes`, |
| 591 | 632 | shows: [openPullTitle, secondChangeTitle], |
| 633 | title: "Every change · vocab", | |
| 592 | 634 | }, |
| 593 | 635 | { |
| 594 | 636 | word: "changes", |
| 595 | 637 | path: `${repoPath}/pulls`, |
| 596 | 638 | shows: [openPullTitle, secondChangeTitle], |
| 597 | 639 | dev: [openBranch], |
| 640 | devTitle: "Pull requests · vocab", | |
| 598 | 641 | }, |
| 599 | 642 | { |
| 600 | 643 | word: "changes (look)", |
| @@ -621,6 +664,8 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 621 | 664 | // out — the filter below is where it shows up. |
| 622 | 665 | hides: [`href="${repoPath}/runs/${stoppedRunId}"`], |
| 623 | 666 | dev: ["Done"], |
| 667 | title: "Every run · vocab", | |
| 668 | devTitle: "Runs · vocab", | |
| 624 | 669 | }, |
| 625 | 670 | { |
| 626 | 671 | word: "runs (stopped)", |
| @@ -641,6 +686,13 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 641 | 686 | hides: [`href="${repoPath}/runs/${workingRunId}"`], |
| 642 | 687 | dev: ["Done"], |
| 643 | 688 | }, |
| 689 | { | |
| 690 | // The studio's own settings: the crew, and how far it may go. | |
| 691 | word: "crew", | |
| 692 | path: `${repoPath}/settings`, | |
| 693 | shows: ["scout"], | |
| 694 | title: "Settings · vocab", | |
| 695 | }, | |
| 644 | 696 | ]; |
| 645 | 697 | }); |
| 646 | 698 | |
| @@ -658,8 +710,9 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 658 | 710 | }); |
| 659 | 711 | // A route not yet wired into the router answers 404 — skip it rather |
| 660 | 712 | // than fail, so a row for a page still being built (docs/STUDIO.md's |
| 661 | // lists, as they land) does not block this file. | |
| 662 | if (res.status === 404) continue; | |
| 713 | // lists, as they land) does not block this file. A row that names its | |
| 714 | // title is a finished page, and never skipped. | |
| 715 | if (res.status === 404 && p.title === undefined) continue; | |
| 663 | 716 | expect(res.status).toBe(200); |
| 664 | 717 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); |
| 665 | 718 | expect(res.headers.get("referrer-policy")).toBe("same-origin"); |
| @@ -678,7 +731,11 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 678 | 731 | // pages, so its presence here is the chrome, proven. |
| 679 | 732 | expect(html).toContain("Studio"); |
| 680 | 733 | |
| 681 | const visible = pageContent(html); | |
| 734 | const title = titleOf(html); | |
| 735 | if (p.title !== undefined) | |
| 736 | expect(title, `${p.word} (${p.path}) titles its tab`).toBe(p.title); | |
| 737 | // The tab is read like `<main>`: the same words, the same reasons. | |
| 738 | const visible = `<title>${title}</title>${pageContent(html)}`; | |
| 682 | 739 | for (const { pattern, because } of DEV_WORDS) { |
| 683 | 740 | expect( |
| 684 | 741 | visible, |
| @@ -763,7 +820,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 763 | 820 | test("?mode=dev on the same routes still carries dev's own words — the assertions above are real, not vacuous", async () => { |
| 764 | 821 | let proved = 0; |
| 765 | 822 | for (const p of pages) { |
| 766 | if (p.dev === undefined) continue; | |
| 823 | if (p.dev === undefined && p.devTitle === undefined) continue; | |
| 767 | 824 | const res = await fetch(`${base}${asDev(p.path)}`, { |
| 768 | 825 | headers: { cookie }, |
| 769 | 826 | redirect: "manual", |
| @@ -775,8 +832,14 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 775 | 832 | // Dev's own words, read where the studio's are read: inside `<main>`, |
| 776 | 833 | // so the shared client script's own literals can never stand in for a |
| 777 | 834 | // page that said nothing. |
| 778 | const visible = pageContent(await res.text()); | |
| 779 | for (const word of p.dev) { | |
| 835 | const html = await res.text(); | |
| 836 | if (p.devTitle !== undefined) | |
| 837 | expect( | |
| 838 | titleOf(html), | |
| 839 | `${p.word} (${asDev(p.path)}) lost dev's own title`, | |
| 840 | ).toBe(p.devTitle); | |
| 841 | const visible = pageContent(html); | |
| 842 | for (const word of p.dev ?? []) { | |
| 780 | 843 | expect( |
| 781 | 844 | visible, |
| 782 | 845 | `${p.word} (${asDev(p.path)}) lost dev's word "${word}" — the studio row for this path proves nothing`, |
| @@ -793,7 +856,42 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 793 | 856 | // Every row that named dev words was actually reached: the 404 skip |
| 794 | 857 | // above is for a page still being built, never a way for a row to stop |
| 795 | 858 | // proving itself once it exists. |
| 796 | expect(proved).toBe(pages.filter((p) => p.dev !== undefined).length); | |
| 859 | expect(proved).toBe( | |
| 860 | pages.filter((p) => p.dev !== undefined || p.devTitle !== undefined) | |
| 861 | .length, | |
| 862 | ); | |
| 863 | }); | |
| 864 | ||
| 865 | test("a repository that is not an app keeps dev's own titles in the studio mode — the same `studioRecord` rule, not a second one (#262)", async () => { | |
| 866 | const alice = getUserByName(app.db, "alice")!; | |
| 867 | const plain = await createRepo(app, alice, { | |
| 868 | name: "plain", | |
| 869 | visibility: "private", | |
| 870 | description: "not an app", | |
| 871 | }); | |
| 872 | if (!plain.ok) throw new Error(String(plain.error)); | |
| 873 | const titles = new Map([ | |
| 874 | ["/alice/plain/issues", "Issues · plain"], | |
| 875 | ["/alice/plain/issues/new", "New issue · plain"], | |
| 876 | ["/alice/plain/pulls", "Pull requests · plain"], | |
| 877 | ["/alice/plain/runs", "Runs · plain"], | |
| 878 | ]); | |
| 879 | for (const [path, title] of titles) { | |
| 880 | const res = await fetch(`${base}${path}`, { | |
| 881 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 882 | redirect: "manual", | |
| 883 | }); | |
| 884 | expect(res.status, path).toBe(200); | |
| 885 | expect(titleOf(await res.text()), path).toBe(title); | |
| 886 | } | |
| 887 | // No studio, no studio door: the compose address under the studio's | |
| 888 | // noun hands a non-app to dev's own. | |
| 889 | const door = await fetch(`${base}/alice/plain/tells/new`, { | |
| 890 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 891 | redirect: "manual", | |
| 892 | }); | |
| 893 | expect(door.status).toBe(303); | |
| 894 | expect(door.headers.get("location")).toBe("/alice/plain/issues/new"); | |
| 797 | 895 | }); |
| 798 | 896 | |
| 799 | 897 | test("reopening a merged change refuses in the reader's own noun — dev's word never rides the flash on a studio page (#171)", async () => { |
new above
offline