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

Merged opened this

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

Merged

scout/forge:scout/8773-the-pull-page-shows-less-at-rest-and-eve → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/8773-the-pull-page-shows-less-at-rest-and-eve was merged into main as 6da2e47.

Review

Sign in to review this change.
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 {
208208 * the repo name steps down to a plain element. Exactly one h1 per document.
209209 */
210210 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;
211213 }
212214
213215 /**
@@ -436,7 +438,7 @@ export function scopeHeader(
436438 <span class="mono">${scope.upstream}</span>
437439 </p>`,
438440 )}
439 ${studio ? studioOriginNote(repo) : whereRow(ctx, scope)}
441 ${studio ? studioOriginNote(repo) : options.quiet ? null : whereRow(ctx, scope)}
440442 ${tabs(items, "Repository")}
441443 </header>`;
442444 }
src/web/pages/thread.ts +3 −1
@@ -500,6 +500,8 @@ export interface ComposerOptions {
500500 * the button tells the crew, and `from` (a path on this forge, or null)
501501 * rides along as a hidden field so the tell remembers the page it was
502502 * 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. */

Non-blocking: plainSend splits the tell doc comment from its field. Put it above that comment.

504 plainSend?: boolean;
503505 tell?: { from: string | null; rows?: number; page?: string; pull?: number; text?: string; ask?: boolean };
504506 }
505507
@@ -613,7 +615,7 @@ export function composer(ctx: Ctx, o: ComposerOptions): Html {
613615 ${reasons} ${closeButton}
614616 ${tell
615617 ? 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" })}
617619 </div>
618620 </form>`;
619621 }
src/web/studio/change.ts +84 −26
@@ -42,7 +42,7 @@ import {
4242 repoHeader,
4343 repoPalette,
4444 } from "../chrome.ts";
45 import { html, when, type Html } from "../html.ts";
45 import { html, raw, when, type Html } from "../html.ts";
4646 import type { IconName as Glyph } from "../icons.ts";
4747 import { threadLine } from "../describe.ts";
4848 import { fragment, page, redirect } from "../layout.ts";
@@ -56,7 +56,6 @@ import {
5656 icon,
5757 originTag,
5858 seg,
59 stateBadge,
6059 time,
6160 tlEvent,
6261 userLink,
@@ -87,7 +86,8 @@ import { pullMayRun } from "../../core/pull_trust.ts";
8786 import { nextOfPull } from "../../flow/next_actor.ts";
8887 import { STEP_MEANS, STEPS, stepOfPull, stepWord, type PathStep } from "../../flow/path.ts";
8988 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";
9191
9292 /** The pull and its repo, or the response that refuses to say why not. */
9393 export function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response {
@@ -207,6 +207,41 @@ function reviewEntry(
207207 };
208208 }
209209
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
210245 /** A change's head, the same on both of its tabs: the title, its state,
211246 * who opened it and in answer to whom, where it came from, and what the
212247 * crew spent on it. */
@@ -226,7 +261,7 @@ export function changeHead(
226261 return html`<div class="page-head">
227262 <h1>${pull.title} <span class="num">#${pull.number}</span></h1>
228263 <div class="page-head-sub bar muted small">
229 ${stateBadge(
264 ${stateDot(
230265 pullState(pull),
231266 getIssueById(ctx.app.db, pull.id)?.closeReason === "parked",
232267 )}
@@ -258,11 +293,7 @@ export function changeHead(
258293 >`,
259294 )}
260295 </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}`))}
266297 </div>`;
267298 }
268299
@@ -271,17 +302,17 @@ export function changeHead(
271302 * marked, and why it waits there. The words link to /pipeline, which says
272303 * what each one means and holds the finer steps.
273304 */
274 export function pathLine(p: PathStep): Html {
305 export function pathLine(p: PathStep, next?: string): Html {
275306 const at = STEPS.indexOf(p.step);
276307 const words = STEPS.map((s, i) => {
277308 const label = stepWord(s);
278309 if (i === at)
279310 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>`;
281312 });
282313 return html`<p class="muted small" data-path="${p.step}">
283314 <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>`}
285316 </p>`;
286317 }
287318
@@ -597,11 +628,10 @@ async function changeConversation(
597628 ? await previewVision(ctx.app, repo, pull)
598629 : null;
599630 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>`,
605635 );
606636
607637 // A shipped change offers one more verb: open a new change that undoes
@@ -609,14 +639,16 @@ async function changeConversation(
609639 // open (nothing shipped to take back yet), never on a mirror (the origin
610640 // owns that history), never twice. The action re-checks all of this and
611641 // does the git work; this only decides whether to offer the button.
612 const takeBackSection = when(
642 const mayTakeBack =
613643 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,
618650 () =>
619 html`<div class="bar mt">
651 html`<div class="bar mt" id="take-back">
620652 ${confirmAction({
621653 action: `${thread}/take-back`,
622654 verb: "Take it back",
@@ -636,16 +668,41 @@ async function changeConversation(
636668 top: html`${repoHeader(ctx, repo, "pulls", {
637669 compact: true,
638670 ownTitle: true,
671 quiet: true,
639672 })}
640673 ${changeHead(ctx, repo, pull, status === undefined ? null : { headOid, conflicts: status.conflicts.length })}
641674 ${changeTabs(repo, pull, "change")}`,
642675 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 ],
644691 }));
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>`;
645702 const gate = tick(
646703 spent,
647704 "gate",
648 () => html`${holdCard(ctx, repo, pull, { act: true })}
705 () => html`${held ? holdCard(ctx, repo, pull, { act: true }) : null}
649706 ${approveHereCard(ctx, repo, pull, headOid, `${thread}/approve-here`)}
650707 ${teamsCard(teamVerdicts(db, { ...pull, headOid }))}
651708 ${when(wall, (w) => wallCard(w, writer ? thread : undefined))}`,
@@ -680,6 +737,7 @@ async function changeConversation(
680737 state: pull.state,
681738 locked: pull.mergedAt !== null,
682739 closeLabel: "Close pull request",
740 plainSend: decision !== undefined,
683741 crewNote: threadNote(ctx.app.db, repo, pull),
684742 });
685743 spent.reviews += performance.now() - reviewsAt;
@@ -696,12 +754,12 @@ async function changeConversation(
696754 <div id="pull-check">${checkLine}</div>
697755 ${when(runLine, (l) => l)} ${gate} ${decisionSection}
698756 ${when(visionPreview, (v) => visionPreviewCard(v))}
699 ${takeBackSection}
700757 ${look}
701758 <div class="split">
702759 <div>
703760 ${talk.node}
704761 ${reply}
762 ${more}
705763 </div>
706764 <aside><div id="pull-preview">${preview}</div></aside>
707765 </div>
src/web/studio/hold.ts +23 −2
@@ -19,7 +19,7 @@ export function holdCard(
1919 ctx: Ctx,
2020 repo: Repo,
2121 pull: Pull,
22 o: { act: boolean },
22 o: { act: boolean; bare?: boolean },
2323 ): Html | null {
2424 const hold = getHold(ctx.app.db, pull.id);
2525 const user = ctx.auth.user;
@@ -31,7 +31,8 @@ export function holdCard(
3131 user.kind === "user" &&
3232 canWrite(ctx.app.db, repo, user);
3333 const thread = `${repoBase(repo)}/pulls/${pull.number}`;
34 if (hold !== undefined)
34 if (hold !== undefined) {
35 if (o.bare) return null;
3536 return card(
3637 html`<p>
3738 ${userLink(hold.heldByName, {
@@ -63,7 +64,27 @@ export function holdCard(
6364 <h2>Held</h2>`,
6465 },
6566 );
67 }
6668 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>`;
6788 return html`<details class="mt mb" id="hold">
6889 <summary class="muted small">Hold this pull request</summary>
6990 <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 {
577577 return html`<span class="dot" data-tone="${tone}" data-word="${word}" role="img" aria-label="${said}"></span>`;
578578 }
579579
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
580590 // ---- what a subscription has left, and what a change costs ----
581591
582592 /** 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", () => {
684684 const cookie = await signIn("alice");
685685 const change = `/alice/garden/pulls/${pull.number}`;
686686 const before = await page(change, cookie);
687 expect(before).toContain(`id="hold"`);
687688 expect(before).toContain("Hold this pull request");
688689 expect(before).toContain(
689690 `action="/alice/garden/pulls/${pull.number}/hold"`,
test/parked-close.test.ts +3 −3
@@ -181,10 +181,10 @@ describe("a parked pull", () => {
181181 setIssueState(app, alice, repo, plain, "closed");
182182
183183 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</);
186186 expect(await get(`/alice/shop/pulls/${plain.number}`)).toMatch(
187 /<\/svg>Closed</,
187 /"word-text">Closed</,
188188 );
189189
190190 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 () => {
6666 const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
6767 const bytes = (t: string) => new TextEncoder().encode(t).length;
6868
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 };
7171
7272 test("the pull page's check is a line with a dot and its word, not a card around a badge", async () => {
7373 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", () => {
621621 await get(`/alice/garden/pulls/${pull.number}`, readerCookie)
622622 ).text();
623623 expect(readerPage).toContain('data-word="look"');
624 expect(readerPage).not.toContain("<h2>Decide</h2>");
624 expect(readerPage).not.toContain(`id="decide"`);
625625 expect(readerPage).not.toContain("Ship");
626626
627627 const ownerPage = await (
628628 await get(`/alice/garden/pulls/${pull.number}`, ownerCookie)
629629 ).text();
630 expect(ownerPage).toContain("<h2>Decide</h2>");
630 expect(ownerPage).toContain(`id="decide"`);
631631 expect(ownerPage).toContain("Ship");
632632 // The words a person's own green change is offered in are `asks.ts`'s
633633 // (#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", () => {
692692 const studio = await (
693693 await get(`/alice/garden/pulls/${pull.number}`, ownerCookie)
694694 ).text();
695 expect(studio).toContain("<h2>Decide</h2>");
695 expect(studio).toContain(`id="decide"`);
696696 expect(studio).toContain("conflicts with main");
697697 expect(studio).toContain("bring the branch up to date");
698698 expect(studio).toContain("Bring up to date");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs