Public: anyone can read this; the crew can change it.
The pull page shows less at rest, and every action it had stays one step away #8803
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8773-the-pull-page-shows-less-at-rest-and-eve → main 4 commits fork 1 thread · 1 unresolved
11 files
changed +277
−38
docs/design/areas/pull.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # Pull | |
| 2 | ||
| 3 | A pull is one change waiting on a person: read it, see its check, ship it or say what is off (#8773). | |
| 4 | ||
| 5 | At rest: the state is a dot (`.dot`, `role=img`, one `aria-label`, no `title`), with the word written beside it once the pull is settled. The check is one muted line. The decision is a plain block holding the page's one primary action (Ship); Comment steps down while it is there. The path line carries "Next: …" in the same paragraph. The where strip is left to the repository's home. | |
| 6 | ||
| 7 | Rare actions (Hold, Take it back) sit in a `<details id="more" class="mt mb">`, the same disclosure Hold always used. Their palette rows link to `?more=1#hold` / `?more=1#take-back`: the server renders the details open for `?more`, so the target is visible with no script. Every other action is a palette row too (`pullActions`, "This pull request"). No new class. | |
| 8 | ||
| 9 | The terminal's pull screen (`src/tui/screens/pull.ts`) draws its own rows and shares no markup with this page; it needs no change. |
src/web/chrome.ts +3 −1
| @@ -208,6 +208,8 @@ export interface RepoHeaderOptions { | ||
| 208 | 208 | * the repo name steps down to a plain element. Exactly one h1 per document. |
| 209 | 209 | */ |
| 210 | 210 | ownTitle?: boolean; |
| 211 | /** Leave the where strip to the repository's home (#8773): a page about one change shows less at rest. */ | |
| 212 | quiet?: boolean; | |
| 211 | 213 | } |
| 212 | 214 | |
| 213 | 215 | /** |
| @@ -436,7 +438,7 @@ export function scopeHeader( | ||
| 436 | 438 | <span class="mono">${scope.upstream}</span> |
| 437 | 439 | </p>`, |
| 438 | 440 | )} |
| 439 | ${studio ? studioOriginNote(repo) : whereRow(ctx, scope)} | |
| 441 | ${studio ? studioOriginNote(repo) : options.quiet ? null : whereRow(ctx, scope)} | |
| 440 | 442 | ${tabs(items, "Repository")} |
| 441 | 443 | </header>`; |
| 442 | 444 | } |
src/web/pages/thread.ts +3 −1
| @@ -500,6 +500,8 @@ export interface ComposerOptions { | ||
| 500 | 500 | * the button tells the crew, and `from` (a path on this forge, or null) |
| 501 | 501 | * rides along as a hidden field so the tell remembers the page it was |
| 502 | 502 | * said on (`actions/tell.ts`). `rows` sizes it; one is a single line. */ |
| 503 | /** The page already has its one primary action; Comment steps down. */ | |
| 504 | plainSend?: boolean; | |
| 503 | 505 | tell?: { from: string | null; rows?: number; page?: string; pull?: number; text?: string; ask?: boolean }; |
| 504 | 506 | } |
| 505 | 507 | |
| @@ -613,7 +615,7 @@ export function composer(ctx: Ctx, o: ComposerOptions): Html { | ||
| 613 | 615 | ${reasons} ${closeButton} |
| 614 | 616 | ${tell |
| 615 | 617 | ? btn("Tell the crew", { variant: "primary", icon: "bot" }) |
| 616 | : btn("Comment", { variant: "primary", icon: "comment" })} | |
| 618 | : btn("Comment", { ...(o.plainSend ? {} : { variant: "primary" as const }), icon: "comment" })} | |
| 617 | 619 | </div> |
| 618 | 620 | </form>`; |
| 619 | 621 | } |
src/web/studio/change.ts +84 −26
| @@ -42,7 +42,7 @@ import { | ||
| 42 | 42 | repoHeader, |
| 43 | 43 | repoPalette, |
| 44 | 44 | } from "../chrome.ts"; |
| 45 | import { html, when, type Html } from "../html.ts"; | |
| 45 | import { html, raw, when, type Html } from "../html.ts"; | |
| 46 | 46 | import type { IconName as Glyph } from "../icons.ts"; |
| 47 | 47 | import { threadLine } from "../describe.ts"; |
| 48 | 48 | import { fragment, page, redirect } from "../layout.ts"; |
| @@ -56,7 +56,6 @@ import { | ||
| 56 | 56 | icon, |
| 57 | 57 | originTag, |
| 58 | 58 | seg, |
| 59 | stateBadge, | |
| 60 | 59 | time, |
| 61 | 60 | tlEvent, |
| 62 | 61 | userLink, |
| @@ -87,7 +86,8 @@ import { pullMayRun } from "../../core/pull_trust.ts"; | ||
| 87 | 86 | import { nextOfPull } from "../../flow/next_actor.ts"; |
| 88 | 87 | import { STEP_MEANS, STEPS, stepOfPull, stepWord, type PathStep } from "../../flow/path.ts"; |
| 89 | 88 | import { changeHref } from "./nouns.ts"; |
| 90 | import { pullWord, servingHistory, wordDot, type WordCtx } from "./words.ts"; | |
| 89 | import { pullWord, servingHistory, stateDot, wordDot, type WordCtx } from "./words.ts"; | |
| 90 | import { getHold } from "../../core/holds.ts"; | |
| 91 | 91 | |
| 92 | 92 | /** The pull and its repo, or the response that refuses to say why not. */ |
| 93 | 93 | export function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response { |
| @@ -207,6 +207,41 @@ function reviewEntry( | ||
| 207 | 207 | }; |
| 208 | 208 | } |
| 209 | 209 | |
| 210 | /** | |
| 211 | * Everything the page does, as palette rows (#8773): the page shows its one | |
| 212 | * primary action and folds the rest away, so each of them is a keystroke | |
| 213 | * from Cmd-K. Only what this viewer may do is offered. | |
| 214 | */ | |
| 215 | export function pullActions(o: { | |
| 216 | base: string; | |
| 217 | name: string; | |
| 218 | number: number; | |
| 219 | open: boolean; | |
| 220 | writer: boolean; | |
| 221 | mayClose: boolean; | |
| 222 | shippable: boolean; | |
| 223 | /** Shipping would only be refused: the verb is the one that fixes it. */ | |
| 224 | conflicted?: boolean; | |
| 225 | takeBack: boolean; | |
| 226 | mayEdit: boolean; | |
| 227 | }): { t: string; h: string; c: string }[] { | |
| 228 | const c = "This pull request"; | |
| 229 | const at = `${o.base}/pulls/${o.number}`; | |
| 230 | const row = (verb: string, h: string) => ({ t: `${o.name} #${o.number} — ${verb}`, h, c }); | |
| 231 | return [ | |
| 232 | row("Conversation", at), | |
| 233 | row("Files", `${at}/files`), | |
| 234 | // The where strip rests on the repository's home; one step from here. | |
| 235 | { t: `${o.name} — Where it is read from`, h: o.base, c }, | |
| 236 | ...(o.shippable ? [row(o.conflicted ? "Bring up to date" : "Ship", `${at}#decide`)] : []), | |
| 237 | ...(o.open && o.writer ? [row("Check again", `${at}#live-checks`), row("Hold", `${at}?more=1#hold`)] : []), | |
| 238 | ...(o.open ? [row("Comment", `${at}#comment-body`)] : []), | |
| 239 | ...(o.open && o.mayClose ? [row("Close pull request", `${at}#comment-body`)] : []), | |
| 240 | ...(o.mayEdit ? [row("Edit", `${at}#edit`)] : []), | |
| 241 | ...(o.takeBack ? [row("Take it back", `${at}?more=1#take-back`)] : []), | |
| 242 | ]; | |
| 243 | } | |
| 244 | ||
| 210 | 245 | /** A change's head, the same on both of its tabs: the title, its state, |
| 211 | 246 | * who opened it and in answer to whom, where it came from, and what the |
| 212 | 247 | * crew spent on it. */ |
| @@ -226,7 +261,7 @@ export function changeHead( | ||
| 226 | 261 | return html`<div class="page-head"> |
| 227 | 262 | <h1>${pull.title} <span class="num">#${pull.number}</span></h1> |
| 228 | 263 | <div class="page-head-sub bar muted small"> |
| 229 | ${stateBadge( | |
| 264 | ${stateDot( | |
| 230 | 265 | pullState(pull), |
| 231 | 266 | getIssueById(ctx.app.db, pull.id)?.closeReason === "parked", |
| 232 | 267 | )} |
| @@ -258,11 +293,7 @@ export function changeHead( | ||
| 258 | 293 | >`, |
| 259 | 294 | )} |
| 260 | 295 | </div> |
| 261 | ${when(step, pathLine)} | |
| 262 | ${when( | |
| 263 | next, | |
| 264 | (n) => html`<p class="next-actor muted small">Next: ${n.who}, ${n.why}.</p>`, | |
| 265 | )} | |
| 296 | ${when(step, (st) => pathLine(st, next === null ? undefined : `${next.who}, ${next.why}`))} | |
| 266 | 297 | </div>`; |
| 267 | 298 | } |
| 268 | 299 | |
| @@ -271,17 +302,17 @@ export function changeHead( | ||
| 271 | 302 | * marked, and why it waits there. The words link to /pipeline, which says |
| 272 | 303 | * what each one means and holds the finer steps. |
| 273 | 304 | */ |
| 274 | export function pathLine(p: PathStep): Html { | |
| 305 | export function pathLine(p: PathStep, next?: string): Html { | |
| 275 | 306 | const at = STEPS.indexOf(p.step); |
| 276 | 307 | const words = STEPS.map((s, i) => { |
| 277 | 308 | const label = stepWord(s); |
| 278 | 309 | if (i === at) |
| 279 | 310 | return html`<strong aria-current="step" title="${STEP_MEANS[s]}">${label}</strong>${p.done ? html` ${badge("done", { tone: "success" })}` : null}`; |
| 280 | return html`<span title="${STEP_MEANS[s]}">${label}</span>`; | |
| 311 | return html`<span>${label}</span>`; | |
| 281 | 312 | }); |
| 282 | 313 | return html`<p class="muted small" data-path="${p.step}"> |
| 283 | 314 | <a href="/pipeline">${words.map((w, i) => html`${i > 0 ? " · " : ""}${w}`)}</a> |
| 284 | — ${p.why} | |
| 315 | — ${p.why}${next === undefined ? "" : html`<span class="next-actor"> Next: ${next}.</span>`} | |
| 285 | 316 | </p>`; |
| 286 | 317 | } |
| 287 | 318 | |
| @@ -597,11 +628,10 @@ async function changeConversation( | ||
| 597 | 628 | ? await previewVision(ctx.app, repo, pull) |
| 598 | 629 | : null; |
| 599 | 630 | const decisionForms = decision ? decisionForm(decision) : null; |
| 600 | const decisionSection = when(decisionBody, (body) => | |
| 601 | card(body, { | |
| 602 | head: html`${icon("alert")} | |
| 603 | <h2>Decide</h2>`, | |
| 604 | }), | |
| 631 | // No card, no heading: the decision is the page's one primary action. | |
| 632 | const decisionSection = when( | |
| 633 | decisionBody, | |
| 634 | (body) => html`<div class="mt mb" id="decide">${body}</div>`, | |
| 605 | 635 | ); |
| 606 | 636 | |
| 607 | 637 | // A shipped change offers one more verb: open a new change that undoes |
| @@ -609,14 +639,16 @@ async function changeConversation( | ||
| 609 | 639 | // open (nothing shipped to take back yet), never on a mirror (the origin |
| 610 | 640 | // owns that history), never twice. The action re-checks all of this and |
| 611 | 641 | // does the git work; this only decides whether to offer the button. |
| 612 | const takeBackSection = when( | |
| 642 | const mayTakeBack = | |
| 613 | 643 | pull.mergedAt !== null && |
| 614 | writer && | |
| 615 | repo.origin === null && | |
| 616 | pull.originHeadRepo === null && | |
| 617 | revertOf(db, repo.id, pull.id) === undefined, | |
| 644 | writer && | |
| 645 | repo.origin === null && | |
| 646 | pull.originHeadRepo === null && | |
| 647 | revertOf(db, repo.id, pull.id) === undefined; | |
| 648 | const takeBackSection = when( | |
| 649 | mayTakeBack, | |
| 618 | 650 | () => |
| 619 | html`<div class="bar mt"> | |
| 651 | html`<div class="bar mt" id="take-back"> | |
| 620 | 652 | ${confirmAction({ |
| 621 | 653 | action: `${thread}/take-back`, |
| 622 | 654 | verb: "Take it back", |
| @@ -636,16 +668,41 @@ async function changeConversation( | ||
| 636 | 668 | top: html`${repoHeader(ctx, repo, "pulls", { |
| 637 | 669 | compact: true, |
| 638 | 670 | ownTitle: true, |
| 671 | quiet: true, | |
| 639 | 672 | })} |
| 640 | 673 | ${changeHead(ctx, repo, pull, status === undefined ? null : { headOid, conflicts: status.conflicts.length })} |
| 641 | 674 | ${changeTabs(repo, pull, "change")}`, |
| 642 | 675 | crumbs: crumbsFor(repo), |
| 643 | palette: repoPalette(ctx, repo), | |
| 676 | palette: [ | |
| 677 | ...repoPalette(ctx, repo), | |
| 678 | ...pullActions({ | |
| 679 | base, | |
| 680 | name: `${repo.ownerName}/${repo.name}`, | |
| 681 | number: pull.number, | |
| 682 | open: pull.state === "open" && pull.mergedAt === null, | |
| 683 | writer, | |
| 684 | mayClose: ctx.auth.user !== undefined && (ctx.auth.user.id === pull.authorId || writer), | |
| 685 | shippable: decision !== undefined, | |
| 686 | conflicted, | |
| 687 | takeBack: mayTakeBack, | |
| 688 | mayEdit: mayEditIssue(ctx.app, repo, pull, ctx.auth.user).ok && !mirrored, | |
| 689 | }), | |
| 690 | ], | |
| 644 | 691 | })); |
| 692 | const held = getHold(db, pull.id) !== undefined; | |
| 693 | const holdForm = held ? null : holdCard(ctx, repo, pull, { act: true, bare: true }); | |
| 694 | // The rare actions, one step away: hold and take back. | |
| 695 | const more = | |
| 696 | holdForm === null && !mayTakeBack | |
| 697 | ? null | |
| 698 | : html`<details class="mt mb" id="more" ${ctx.url.searchParams.has("more") ? raw("open") : null}> | |
| 699 | <summary class="muted small">More: hold, take it back</summary> | |
| 700 | ${holdForm}${takeBackSection} | |
| 701 | </details>`; | |
| 645 | 702 | const gate = tick( |
| 646 | 703 | spent, |
| 647 | 704 | "gate", |
| 648 | () => html`${holdCard(ctx, repo, pull, { act: true })} | |
| 705 | () => html`${held ? holdCard(ctx, repo, pull, { act: true }) : null} | |
| 649 | 706 | ${approveHereCard(ctx, repo, pull, headOid, `${thread}/approve-here`)} |
| 650 | 707 | ${teamsCard(teamVerdicts(db, { ...pull, headOid }))} |
| 651 | 708 | ${when(wall, (w) => wallCard(w, writer ? thread : undefined))}`, |
| @@ -680,6 +737,7 @@ async function changeConversation( | ||
| 680 | 737 | state: pull.state, |
| 681 | 738 | locked: pull.mergedAt !== null, |
| 682 | 739 | closeLabel: "Close pull request", |
| 740 | plainSend: decision !== undefined, | |
| 683 | 741 | crewNote: threadNote(ctx.app.db, repo, pull), |
| 684 | 742 | }); |
| 685 | 743 | spent.reviews += performance.now() - reviewsAt; |
| @@ -696,12 +754,12 @@ async function changeConversation( | ||
| 696 | 754 | <div id="pull-check">${checkLine}</div> |
| 697 | 755 | ${when(runLine, (l) => l)} ${gate} ${decisionSection} |
| 698 | 756 | ${when(visionPreview, (v) => visionPreviewCard(v))} |
| 699 | ${takeBackSection} | |
| 700 | 757 | ${look} |
| 701 | 758 | <div class="split"> |
| 702 | 759 | <div> |
| 703 | 760 | ${talk.node} |
| 704 | 761 | ${reply} |
| 762 | ${more} | |
| 705 | 763 | </div> |
| 706 | 764 | <aside><div id="pull-preview">${preview}</div></aside> |
| 707 | 765 | </div> |
src/web/studio/hold.ts +23 −2
| @@ -19,7 +19,7 @@ export function holdCard( | ||
| 19 | 19 | ctx: Ctx, |
| 20 | 20 | repo: Repo, |
| 21 | 21 | pull: Pull, |
| 22 | o: { act: boolean }, | |
| 22 | o: { act: boolean; bare?: boolean }, | |
| 23 | 23 | ): Html | null { |
| 24 | 24 | const hold = getHold(ctx.app.db, pull.id); |
| 25 | 25 | const user = ctx.auth.user; |
| @@ -31,7 +31,8 @@ export function holdCard( | ||
| 31 | 31 | user.kind === "user" && |
| 32 | 32 | canWrite(ctx.app.db, repo, user); |
| 33 | 33 | const thread = `${repoBase(repo)}/pulls/${pull.number}`; |
| 34 | if (hold !== undefined) | |
| 34 | if (hold !== undefined) { | |
| 35 | if (o.bare) return null; | |
| 35 | 36 | return card( |
| 36 | 37 | html`<p> |
| 37 | 38 | ${userLink(hold.heldByName, { |
| @@ -63,7 +64,27 @@ export function holdCard( | ||
| 63 | 64 | <h2>Held</h2>`, |
| 64 | 65 | }, |
| 65 | 66 | ); |
| 67 | } | |
| 66 | 68 | if (!may || !open) return null; |
| 69 | // Folded into the page's "More": the form alone, with the anchor. | |
| 70 | if (o.bare) | |
| 71 | return html`<form class="bar mt" id="hold" method="post" action="${thread}/hold"> | |
| 72 | <label class="muted small" for="hold-why">Hold this pull request</label> | |
| 73 | <input | |
| 74 | id="hold-why" | |
| 75 | class="input" | |
| 76 | name="why" | |
| 77 | required | |
| 78 | maxlength="${WHY_MAX}" | |
| 79 | placeholder="Why, in one line" | |
| 80 | aria-label="Why it is held, in one line" | |
| 81 | /> | |
| 82 | <label class="check" | |
| 83 | ><input type="checkbox" name="until" value="green" /> | |
| 84 | <span>until green</span></label | |
| 85 | > | |
| 86 | ${btn("Hold", { size: "sm" })} | |
| 87 | </form>`; | |
| 67 | 88 | return html`<details class="mt mb" id="hold"> |
| 68 | 89 | <summary class="muted small">Hold this pull request</summary> |
| 69 | 90 | <form class="bar mt" method="post" action="${thread}/hold"> |
src/web/studio/words.ts +10
| @@ -577,6 +577,16 @@ export function wordDot(word: Word, note?: string): Html { | ||
| 577 | 577 | return html`<span class="dot" data-tone="${tone}" data-word="${word}" role="img" aria-label="${said}"></span>`; |
| 578 | 578 | } |
| 579 | 579 | |
| 580 | /** A pull's own state at rest: the dot, its word for a screen reader. */ | |
| 581 | export function stateDot(state: "open" | "closed" | "merged", parked = false): Html { | |
| 582 | const said = state === "closed" && parked ? "parked" : state; | |
| 583 | const tone = state === "open" ? "success" : state === "merged" ? "merged" : "none"; | |
| 584 | // Open is the quiet state; a settled one is written out beside its dot. | |
| 585 | return html`<span class="dot" data-state="${state}" data-tone="${tone}" role="img" aria-label="${said}"></span>${ | |
| 586 | state === "open" ? null : html`<span class="word-text">${said === "merged" ? "Merged" : said === "parked" ? "Parked" : "Closed"}</span>` | |
| 587 | }`; | |
| 588 | } | |
| 589 | ||
| 580 | 590 | // ---- what a subscription has left, and what a change costs ---- |
| 581 | 591 | |
| 582 | 592 | /** One figure as a person reads it, and whether the forge worked it out |
test/holds.test.ts +1
| @@ -684,6 +684,7 @@ describe("a hold is set, shown and lifted", () => { | ||
| 684 | 684 | const cookie = await signIn("alice"); |
| 685 | 685 | const change = `/alice/garden/pulls/${pull.number}`; |
| 686 | 686 | const before = await page(change, cookie); |
| 687 | expect(before).toContain(`id="hold"`); | |
| 687 | 688 | expect(before).toContain("Hold this pull request"); |
| 688 | 689 | expect(before).toContain( |
| 689 | 690 | `action="/alice/garden/pulls/${pull.number}/hold"`, |
test/parked-close.test.ts +3 −3
| @@ -181,10 +181,10 @@ describe("a parked pull", () => { | ||
| 181 | 181 | setIssueState(app, alice, repo, plain, "closed"); |
| 182 | 182 | |
| 183 | 183 | const page = await get(`/alice/shop/pulls/${parked.number}`); |
| 184 | expect(page).toMatch(/data-state="closed"[^>]*>[^<]*<svg[\s\S]*?<\/svg>Parked</); | |
| 185 | expect(page).not.toMatch(/<\/svg>Closed</); | |
| 184 | expect(page).toMatch(/data-state="closed"[\s\S]*?"word-text">Parked</); | |
| 185 | expect(page).not.toMatch(/"word-text">Closed</); | |
| 186 | 186 | expect(await get(`/alice/shop/pulls/${plain.number}`)).toMatch( |
| 187 | /<\/svg>Closed</, | |
| 187 | /"word-text">Closed</, | |
| 188 | 188 | ); |
| 189 | 189 | |
| 190 | 190 | const list = await get("/alice/shop/pulls?word=stopped"); |
test/pull-actions.test.ts added +136
| @@ -0,0 +1,136 @@ | ||
| 1 | /** The pull page at rest (#8773): status as a dot, the check as one line, the rare actions folded into "More" — and every action it ever offered still on the page or in the palette. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts"; | |
| 4 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 5 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 6 | import { pullActions } from "../src/web/studio/change.ts"; | |
| 7 | ||
| 8 | let f: SnappyFixture; | |
| 9 | let base = ""; | |
| 10 | let number = 0; | |
| 11 | beforeAll(async () => { | |
| 12 | f = await snappyFixture({ repos: 1, issuesPerRepo: 2 }); | |
| 13 | const repo = f.repos[0]!; | |
| 14 | base = `/${repo.ownerName}/${repo.name}`; | |
| 15 | for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) { | |
| 16 | const done = await commitToBranch(f.app, f.owner, repo, { | |
| 17 | ...(branch === undefined ? {} : { branch }), | |
| 18 | message: "change", | |
| 19 | files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }], | |
| 20 | }); | |
| 21 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 22 | } | |
| 23 | const made = await openPull(f.app, f.owner, repo, { | |
| 24 | title: "A change", | |
| 25 | body: "Body", | |
| 26 | headBranch: "topic", | |
| 27 | baseBranch: "main", | |
| 28 | }); | |
| 29 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 30 | number = made.value.number; | |
| 31 | }, 120_000); | |
| 32 | afterAll(async () => { | |
| 33 | await f.close(); | |
| 34 | }); | |
| 35 | ||
| 36 | const get = async (path: string): Promise<string> => | |
| 37 | await (await fetch(`${f.base}${base}${path}`, { headers: { cookie: f.ownerCookie } })).text(); | |
| 38 | ||
| 39 | test("the page wears a dot for its state, and has exactly one primary action", async () => { | |
| 40 | const text = await get(`/pulls/${number}`); | |
| 41 | expect(text).toMatch(/class="dot"[^>]*aria-label="[^"]+"/); | |
| 42 | expect(text).not.toMatch(/class="dot"[^>]*title=/); | |
| 43 | expect(text.match(/data-variant="primary"/g)?.length ?? 0).toBe(1); // Ship; Comment steps down | |
| 44 | }); | |
| 45 | ||
| 46 | test("every action the page offered is still on it", async () => { | |
| 47 | const text = await get(`/pulls/${number}`); | |
| 48 | const at = `${base}/pulls/${number}`; | |
| 49 | for (const needle of [ | |
| 50 | `href="${at}"`, // Conversation | |
| 51 | `href="${at}/files"`, // Files | |
| 52 | `action="${at}/ship"`, // merge | |
| 53 | `action="${at}/checks"`, // check again | |
| 54 | `action="${at}/hold"`, // hold | |
| 55 | `action="${at}/comments"`, // comment | |
| 56 | `action="${at}/state"`, // close | |
| 57 | `action="${at}/edit"`, // edit | |
| 58 | `href="${base}/branches"`, // branch | |
| 59 | `href="${base}/commits"`, | |
| 60 | `href="${base}/runs"`, | |
| 61 | ]) | |
| 62 | expect(text).toContain(needle); | |
| 63 | }); | |
| 64 | ||
| 65 | test("every action is in the palette", async () => { | |
| 66 | const text = await get(`/pulls/${number}`); | |
| 67 | const at = `${base}/pulls/${number}`; | |
| 68 | for (const h of [ | |
| 69 | at, | |
| 70 | `${at}/files`, | |
| 71 | `${at}#decide`, | |
| 72 | `${at}#live-checks`, | |
| 73 | `${at}?more=1#hold`, | |
| 74 | `${at}#comment-body`, | |
| 75 | `${at}#edit`, | |
| 76 | ]) | |
| 77 | expect(text).toContain(`"h":"${h}"`); | |
| 78 | for (const t of ["Ship", "Check again", "Hold", "Comment", "Close pull request", "Edit"]) | |
| 79 | expect(text).toContain(`#${number} — ${t}`); | |
| 80 | }); | |
| 81 | ||
| 82 | test("pullActions offers what the viewer may do and nothing else", () => { | |
| 83 | const o = { base: "/a/b", name: "a/b", number: 7 }; | |
| 84 | const labels = (x: Parameters<typeof pullActions>[0]) => pullActions(x).map((i) => i.t); | |
| 85 | const all = labels({ ...o, open: true, writer: true, mayClose: true, shippable: true, takeBack: false, mayEdit: true }); | |
| 86 | for (const w of ["Conversation", "Files", "Ship", "Check again", "Hold", "Comment", "Close pull request", "Edit"]) | |
| 87 | expect(all.some((t) => t.includes(w))).toBe(true); | |
| 88 | const merged = labels({ ...o, open: false, writer: true, mayClose: false, shippable: false, takeBack: true, mayEdit: false }); | |
| 89 | expect(merged.some((t) => t.includes("Take it back"))).toBe(true); | |
| 90 | expect(merged.some((t) => t.includes("Ship"))).toBe(false); | |
| 91 | const reader = labels({ ...o, open: true, writer: false, mayClose: false, shippable: false, takeBack: false, mayEdit: false }); | |
| 92 | expect(reader.length).toBeLessThanOrEqual(4); | |
| 93 | }); | |
| 94 | ||
| 95 | const getAs = async (cookie: string, path: string): Promise<string> => | |
| 96 | await (await fetch(`${f.base}${base}${path}`, { headers: { cookie } })).text(); | |
| 97 | ||
| 98 | test("the palette's Hold and Take it back land on an opened More, with no script", async () => { | |
| 99 | const at = `${base}/pulls/${number}`; | |
| 100 | const text = await get(`/pulls/${number}`); | |
| 101 | expect(text).toContain(`"h":"${at}?more=1#hold"`); | |
| 102 | const closed = await get(`/pulls/${number}`); | |
| 103 | expect(closed).toMatch(/<details[^>]*id="more"(?![^>]*\bopen\b)[^>]*>/); | |
| 104 | const opened = await get(`/pulls/${number}?more=1`); | |
| 105 | expect(opened).toMatch(/<details[^>]*id="more"[^>]*\bopen\b[^>]*>/); | |
| 106 | expect(opened).toContain("Hold this pull request"); | |
| 107 | expect(opened).toContain('id="hold"'); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a reader sees no private action, on the page or in the palette", async () => { | |
| 111 | const text = await getAs(f.peerCookie, `/pulls/${number}`); | |
| 112 | const at = `${base}/pulls/${number}`; | |
| 113 | for (const needle of [`action="${at}/ship"`, `action="${at}/hold"`, `action="${at}/checks"`, `action="${at}/edit"`, `id="more"`, "#hold", "#decide", "#edit"]) | |
| 114 | expect(text).not.toContain(needle); | |
| 115 | }); | |
| 116 | ||
| 117 | test("a merged pull offers Take it back on the page and in the palette, behind More", async () => { | |
| 118 | const repo = f.repos[0]!; | |
| 119 | const done = await commitToBranch(f.app, f.owner, repo, { | |
| 120 | branch: "topic2", | |
| 121 | message: "again", | |
| 122 | files: [{ path: "u.txt", content: "y" }], | |
| 123 | }); | |
| 124 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 125 | const made = await openPull(f.app, f.owner, repo, { title: "Second", body: "", headBranch: "topic2", baseBranch: "main" }); | |
| 126 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 127 | const merged = await mergePull(f.app, f.owner, repo, made.value, "squash"); | |
| 128 | if (!merged.ok) throw new Error(JSON.stringify(merged.error)); | |
| 129 | const n = made.value.number; | |
| 130 | const text = await get(`/pulls/${n}`); | |
| 131 | expect(text).toContain('id="take-back"'); | |
| 132 | expect(text).toContain(`"h":"${base}/pulls/${n}?more=1#take-back"`); | |
| 133 | expect(text).toMatch(/<details[^>]*id="more"/); | |
| 134 | expect(await get(`/pulls/${n}?more=1`)).toMatch(/<details[^>]*id="more"[^>]*\bopen\b/); | |
| 135 | expect(await getAs(f.peerCookie, `/pulls/${n}`)).not.toContain("take-back"); | |
| 136 | }); |
test/pull-quiet.test.ts +2 −2
| @@ -66,8 +66,8 @@ afterAll(async () => { | ||
| 66 | 66 | const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); |
| 67 | 67 | const bytes = (t: string) => new TextEncoder().encode(t).length; |
| 68 | 68 | |
| 69 | // Measured 31,622 and 24,540 (before: 31,751 and 24,738). The margin covers the port digits and times in the page (up to ~160 B seen), so it holds the page from growing, not a tighter squeeze. | |
| 70 | const BUDGET = { pull: 32_000, list: 24_900 }; | |
| 69 | // Measured 30,889 and 24,540 (before: 31,622 on main with #8800, 31,968 before #8773 and 24,738). The margin covers the port digits and times in the page (up to ~160 B seen), so it holds the page from growing, not a tighter squeeze. | |
| 70 | const BUDGET = { pull: 31_100, list: 24_900 }; | |
| 71 | 71 | |
| 72 | 72 | test("the pull page's check is a line with a dot and its word, not a card around a badge", async () => { |
| 73 | 73 | const text = await get(`/alice/r/pulls/${number}`); |
test/studio-shipping.test.ts +3 −3
| @@ -621,13 +621,13 @@ describe("a person's own change ships from its own studio page", () => { | ||
| 621 | 621 | await get(`/alice/garden/pulls/${pull.number}`, readerCookie) |
| 622 | 622 | ).text(); |
| 623 | 623 | expect(readerPage).toContain('data-word="look"'); |
| 624 | expect(readerPage).not.toContain("<h2>Decide</h2>"); | |
| 624 | expect(readerPage).not.toContain(`id="decide"`); | |
| 625 | 625 | expect(readerPage).not.toContain("Ship"); |
| 626 | 626 | |
| 627 | 627 | const ownerPage = await ( |
| 628 | 628 | await get(`/alice/garden/pulls/${pull.number}`, ownerCookie) |
| 629 | 629 | ).text(); |
| 630 | expect(ownerPage).toContain("<h2>Decide</h2>"); | |
| 630 | expect(ownerPage).toContain(`id="decide"`); | |
| 631 | 631 | expect(ownerPage).toContain("Ship"); |
| 632 | 632 | // The words a person's own green change is offered in are `asks.ts`'s |
| 633 | 633 | // (#224: a green change asks to be looked at whoever opened it, and the |
| @@ -692,7 +692,7 @@ describe("a person's own change ships from its own studio page", () => { | ||
| 692 | 692 | const studio = await ( |
| 693 | 693 | await get(`/alice/garden/pulls/${pull.number}`, ownerCookie) |
| 694 | 694 | ).text(); |
| 695 | expect(studio).toContain("<h2>Decide</h2>"); | |
| 695 | expect(studio).toContain(`id="decide"`); | |
| 696 | 696 | expect(studio).toContain("conflicts with main"); |
| 697 | 697 | expect(studio).toContain("bring the branch up to date"); |
| 698 | 698 | expect(studio).toContain("Bring up to date"); |
new above
offline
Non-blocking: plainSend splits the tell doc comment from its field. Put it above that comment.