Public: anyone can read this; the crew can change it.
/asks renders what needs you as its own page, and every test reads asks from /asks instead of the home #2804
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2799-asks-renders-what-needs-you-as-its-own-p → main 2 commits fork 1 thread · 1 unresolved
12 files
changed +74
−60
src/web/pages/board.ts +2 −2
| @@ -394,7 +394,7 @@ function reviewStrip(ctx: Ctx, scope: BoardScope): Html | null { | ||
| 394 | 394 | if (!none && waits.length === 0) return []; |
| 395 | 395 | const oldest = waits.map((w) => w.since).sort()[0]; |
| 396 | 396 | return [ |
| 397 | html`<a class="board-asks" href="/#needs-you" | |
| 397 | html`<a class="board-asks" href="/asks" | |
| 398 | 398 | ><b>${repo.name}</b>: ${none ? "No reviewer: agent pulls wait for you" : "agent pulls wait for your review"}${waits.length === 0 |
| 399 | 399 | ? null |
| 400 | 400 | : html` · <b>${waits.length}</b> waiting since ${oldest!.slice(0, 10)}`}</a |
| @@ -421,7 +421,7 @@ function asksStrip(ctx: Ctx): Html | null { | ||
| 421 | 421 | const since = new Date(Date.now() - ASKS_WINDOW_DAYS * 86_400_000).toISOString(); |
| 422 | 422 | const m = askMeasure(ctx.app.db, user.id, since); |
| 423 | 423 | if (m.raised + m.acted + m.dismissed + m.expired === 0) return null; |
| 424 | return html`<a class="board-asks" id="asks-measure" href="/#needs-you" | |
| 424 | return html`<a class="board-asks" id="asks-measure" href="/asks" | |
| 425 | 425 | >Asks, ${ASKS_WINDOW_DAYS} days: <b>${m.raised}</b> raised · <b>${m.acted}</b> acted |
| 426 | 426 | on · <b>${m.dismissed}</b> dismissed · <b>${m.expired}</b> went by themselves</a |
| 427 | 427 | >`; |
src/web/studio/asks_page.ts +30 −23
| @@ -27,7 +27,9 @@ import { needLine } from "../../actions/health.ts"; | ||
| 27 | 27 | import type { Degraded } from "../../kernel/vitals.ts"; |
| 28 | 28 | import type { Ctx } from "../../http/context.ts"; |
| 29 | 29 | import type { User } from "../../core/users.ts"; |
| 30 | import { onYouAnswer, studioHome } from "./home.ts"; | |
| 30 | import { onYouAnswer, onYouRegion } from "./home.ts"; | |
| 31 | import { liveRegionsJs } from "../client.ts"; | |
| 32 | import { liveClientJs } from "../live.ts"; | |
| 31 | 33 | import { notFoundPage, repoBase, signInFirst } from "../chrome.ts"; |
| 32 | 34 | import { backTo, field, readForm } from "../form.ts"; |
| 33 | 35 | import { MAX_BODY } from "../../core/issues.ts"; |
| @@ -40,7 +42,7 @@ import { | ||
| 40 | 42 | ownerFactRepoIds, |
| 41 | 43 | } from "../../core/repos.ts"; |
| 42 | 44 | import { html, type Html } from "../html.ts"; |
| 43 | import { redirect } from "../layout.ts"; | |
| 45 | import { page, redirect } from "../layout.ts"; | |
| 44 | 46 | import { btn, card, icon, row } from "../ui.ts"; |
| 45 | 47 | import { |
| 46 | 48 | askAnchor, |
| @@ -332,15 +334,23 @@ export interface AsksTop { | ||
| 332 | 334 | readonly reason?: string; |
| 333 | 335 | } |
| 334 | 336 | |
| 335 | /** GET /asks — the home, scrolled to what needs you; `?op=<id>` (a bulk | |
| 336 | * action's receipt) rides along. */ | |
| 337 | /** GET /asks — what needs you as its own page: the home's section and its | |
| 338 | * live script; `?op=<id>` (a bulk action's receipt) rides along. */ | |
| 337 | 339 | export function asksPage(ctx: Ctx): Response { |
| 338 | const op = ctx.url.searchParams.get("op"); | |
| 339 | return redirect( | |
| 340 | op === null | |
| 341 | ? "/#needs-you" | |
| 342 | : `/?op=${encodeURIComponent(op)}#asks-bulk-result`, | |
| 343 | ); | |
| 340 | return renderAsks(ctx, homeTop(ctx)); | |
| 341 | } | |
| 342 | ||
| 343 | /** The asks page, with a bulk step's preview or receipt on top. */ | |
| 344 | function renderAsks(ctx: Ctx, top: AsksTop | undefined): Response { | |
| 345 | const user = ctx.auth.user; | |
| 346 | if (!user) return signInFirst(ctx); | |
| 347 | const section = needsYouSection(ctx, user, top); | |
| 348 | return page({ | |
| 349 | ctx, | |
| 350 | title: "Asks", | |
| 351 | body: html`<main class="stack"><h1 class="sr-only">Asks</h1>${onYouRegion(ctx, user, undefined, section.body)}</main>`, | |
| 352 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${section.scriptJs}`, | |
| 353 | }); | |
| 344 | 354 | } |
| 345 | 355 | |
| 346 | 356 | /** The receipt `?op=<id>` names on the home, when it is the viewer's own. */ |
| @@ -487,12 +497,9 @@ export function needsYouSection( | ||
| 487 | 497 | return { body, scriptJs: SELECT_JS }; |
| 488 | 498 | } |
| 489 | 499 | |
| 490 | /** The home with a bulk step's preview or receipt on top of what needs | |
| 491 | * you — where every bulk answer lands. */ | |
| 500 | /** The asks page with a bulk step's preview or receipt on top — where every bulk answer lands. */ | |
| 492 | 501 | async function asksScreen(ctx: Ctx, top: AsksTop): Promise<Response> { |
| 493 | const user = ctx.auth.user; | |
| 494 | if (!user) return signInFirst(ctx); | |
| 495 | return await studioHome(ctx, user, top); | |
| 502 | return renderAsks(ctx, top); | |
| 496 | 503 | } |
| 497 | 504 | |
| 498 | 505 | // ---- many at once: select, preview, do it, undo — all on this screen ---- |
| @@ -641,7 +648,7 @@ export async function asksBulkSubmit(ctx: Ctx): Promise<Response> { | ||
| 641 | 648 | : `${why.length} not done: ${[...new Set(why.filter((w) => w !== ""))].join("; ") || "refused"}`; |
| 642 | 649 | return onYouAnswer(ctx, ctx.auth.user, note); |
| 643 | 650 | } |
| 644 | return redirect(`/?op=${op.value.id}#asks-bulk-result`); | |
| 651 | return redirect(`/asks?op=${op.value.id}#asks-bulk-result`); | |
| 645 | 652 | } |
| 646 | 653 | const plan = previewAsksBulk(ctx.app, ctx.auth.user, action, items, reason); |
| 647 | 654 | if (!plan.ok) return await asksScreen(ctx, nothingDone(plan.error.message)); |
| @@ -675,7 +682,7 @@ export async function asksBulkUndo(ctx: Ctx): Promise<Response> { | ||
| 675 | 682 | return op.error.code === "not_found" |
| 676 | 683 | ? notFoundPage(ctx) |
| 677 | 684 | : await asksScreen(ctx, nothingDone(op.error.message)); |
| 678 | return redirect(`/?op=${op.value.id}#asks-bulk-result`); | |
| 685 | return redirect(`/asks?op=${op.value.id}#asks-bulk-result`); | |
| 679 | 686 | } |
| 680 | 687 | |
| 681 | 688 | /** POST /asks/decide — hand one hand-over card to the decider; the ledger keeps what it saw. */ |
| @@ -685,7 +692,7 @@ export async function asksDecide(ctx: Ctx): Promise<Response> { | ||
| 685 | 692 | if (form instanceof Response) return form; |
| 686 | 693 | const done = await decideAsk(ctx.app, ctx.auth.user, field(form, "ask")); |
| 687 | 694 | if (!done.ok) return await asksScreen(ctx, nothingDone(done.error.message)); |
| 688 | return redirect(`/?decided=${done.value.id}#asks-bulk-result`); | |
| 695 | return redirect(`/asks?decided=${done.value.id}#asks-bulk-result`); | |
| 689 | 696 | } |
| 690 | 697 | |
| 691 | 698 | /** POST /asks/proposal/:id/(approve|redirect|later|again) — one wave proposal, then back to the page the person came from. */ |
| @@ -701,7 +708,7 @@ export function asksProposal(verb: ProposalVerb) { | ||
| 701 | 708 | : done.error.code === "forbidden" || done.error.code === "unauthorized" |
| 702 | 709 | ? new Response("forbidden", { status: 403 }) |
| 703 | 710 | : await asksScreen(ctx, nothingDone(done.error.message)); |
| 704 | return redirect(backTo(form, "/")); | |
| 711 | return redirect(backTo(form, "/asks")); | |
| 705 | 712 | }; |
| 706 | 713 | } |
| 707 | 714 | |
| @@ -719,7 +726,7 @@ export async function asksSetting(ctx: Ctx): Promise<Response> { | ||
| 719 | 726 | }; |
| 720 | 727 | return await asksScreen(ctx, nothingDone(why[done.error] ?? "it no longer asks you")); |
| 721 | 728 | } |
| 722 | return redirect("/"); | |
| 729 | return redirect("/asks"); | |
| 723 | 730 | } |
| 724 | 731 | |
| 725 | 732 | /** POST /asks/decide/:id/undo — put a set-aside card back on the board, within a day. */ |
| @@ -732,7 +739,7 @@ export async function asksDecideUndo(ctx: Ctx): Promise<Response> { | ||
| 732 | 739 | return undone.error.code === "not_found" |
| 733 | 740 | ? notFoundPage(ctx) |
| 734 | 741 | : await asksScreen(ctx, nothingDone(undone.error.message)); |
| 735 | return redirect(`/?decided=${undone.value.id}#asks-bulk-result`); | |
| 742 | return redirect(`/asks?decided=${undone.value.id}#asks-bulk-result`); | |
| 736 | 743 | } |
| 737 | 744 | |
| 738 | 745 | /** POST /asks/listen — take back the viewer's own mute of the repositories named. */ |
| @@ -745,7 +752,7 @@ export async function asksListen(ctx: Ctx): Promise<Response> { | ||
| 745 | 752 | .filter((r): r is string => typeof r === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(r)) |
| 746 | 753 | .slice(0, BULK_MAX); |
| 747 | 754 | removeMutes(ctx.app.db, ctx.auth.user.id, repos.map((r) => `repo:${r}`)); |
| 748 | return redirect("/"); | |
| 755 | return redirect("/asks"); | |
| 749 | 756 | } |
| 750 | 757 | |
| 751 | 758 | /** POST /asks/mute — the viewer mutes one repository they can read: its asks leave their home, inbox and phone. */ |
| @@ -759,5 +766,5 @@ export async function asksMute(ctx: Ctx): Promise<Response> { | ||
| 759 | 766 | const repo = getRepoById(ctx.app.db, id); |
| 760 | 767 | if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) return notFoundPage(ctx); |
| 761 | 768 | muteRepo(ctx.app.db, ctx.auth.user.id, repo.id); |
| 762 | return redirect("/"); | |
| 769 | return redirect("/asks"); | |
| 763 | 770 | } |
src/web/studio/home.ts +1 −1
| @@ -155,7 +155,7 @@ const appsRegion = (ctx: Ctx, user: User): Html => | ||
| 155 | 155 | ${yourApps(ctx, user)} |
| 156 | 156 | </div>`; |
| 157 | 157 | |
| 158 | const onYouRegion = ( | |
| 158 | export const onYouRegion = ( | |
| 159 | 159 | ctx: Ctx, |
| 160 | 160 | user: User, |
| 161 | 161 | note?: string, |
test/asks-bulk.test.ts +1 −1
| @@ -76,7 +76,7 @@ describe("acting on many asks at once", () => { | ||
| 76 | 76 | }); |
| 77 | 77 | const location = res.headers.get("location"); |
| 78 | 78 | if (res.status === 303 && location !== null) { |
| 79 | expect(location).toStartWith("/?op="); | |
| 79 | expect(location).toStartWith("/asks?op="); | |
| 80 | 80 | const next = await fetch(`${servers.url}${location}`, { headers, redirect: "manual" }); |
| 81 | 81 | expect(next.status).toBe(200); |
| 82 | 82 | return (await next.text()).replace(/\s+/g, " "); |
test/asks-expiry.test.ts +7 −7
| @@ -126,7 +126,7 @@ describe("asks that expire, hold and collapse", () => { | ||
| 126 | 126 | pears = await botPull("pears", [{ path: "PEARS.md", content: "pears\n" }]); |
| 127 | 127 | green(apples); |
| 128 | 128 | green(pears); |
| 129 | const page = await get("/"); | |
| 129 | const page = await get("/asks"); | |
| 130 | 130 | expect(sentence(page)).toBe("1 thing needs you"); |
| 131 | 131 | expect(headerCount(page)).toBe(1); |
| 132 | 132 | expect(page).toContain("2 green changes wait for your look"); |
| @@ -152,7 +152,7 @@ describe("asks that expire, hold and collapse", () => { | ||
| 152 | 152 | // card at all, so there is nothing to mute. |
| 153 | 153 | const fragment = await get("/live/feed"); |
| 154 | 154 | expect(fragment).not.toContain('data-kind="ask"'); |
| 155 | const home = await get("/"); | |
| 155 | const home = await get("/asks"); | |
| 156 | 156 | expect(home).toContain('<section id="needs-you"'); |
| 157 | 157 | const at = home.indexOf('<section id="needs-you"'); |
| 158 | 158 | expect(home.slice(at, home.indexOf("</section>", at))).not.toContain( |
| @@ -169,7 +169,7 @@ describe("asks that expire, hold and collapse", () => { | ||
| 169 | 169 | repoId: repo.id, |
| 170 | 170 | actorId: null, |
| 171 | 171 | }); |
| 172 | let page = await get("/"); | |
| 172 | let page = await get("/asks"); | |
| 173 | 173 | expect(sentence(page)).toBe("All fine"); |
| 174 | 174 | expect(headerCount(page)).toBe(0); |
| 175 | 175 | expect(askLinesFor(app.db, aliceUser)).toHaveLength(0); |
| @@ -179,7 +179,7 @@ describe("asks that expire, hold and collapse", () => { | ||
| 179 | 179 | // A new check on one of them: that one asks again, on its own. |
| 180 | 180 | await tick(); |
| 181 | 181 | green(apples); |
| 182 | page = await get("/"); | |
| 182 | page = await get("/asks"); | |
| 183 | 183 | expect(sentence(page)).toBe("1 thing needs you"); |
| 184 | 184 | const pinned = page.slice( |
| 185 | 185 | page.indexOf('<section id="needs-you"'), |
| @@ -203,7 +203,7 @@ describe("asks that expire, hold and collapse", () => { | ||
| 203 | 203 | repoId: repo.id, |
| 204 | 204 | actorId: null, |
| 205 | 205 | }); |
| 206 | expect(sentence(await get("/"))).toBe("All fine"); | |
| 206 | expect(sentence(await get("/asks"))).toBe("All fine"); | |
| 207 | 207 | // The merge box is the change's Files step (#628). |
| 208 | 208 | const change = await get( |
| 209 | 209 | `/${repo.ownerName}/${repo.name}/pulls/${apples.number}/files`, |
| @@ -231,7 +231,7 @@ describe("asks that expire, hold and collapse", () => { | ||
| 231 | 231 | }); |
| 232 | 232 | await sweepStaleAsks(app); |
| 233 | 233 | expect(askLinesFor(app.db, aliceUser)).toHaveLength(0); |
| 234 | expect(sentence(await get("/"))).toBe("All fine"); | |
| 234 | expect(sentence(await get("/asks"))).toBe("All fine"); | |
| 235 | 235 | // The feed and the MCP's asks carry no green line for it either. |
| 236 | 236 | expect( |
| 237 | 237 | readFeed(app.db, aliceUser, { limit: 50 }).items.some( |
| @@ -241,7 +241,7 @@ describe("asks that expire, hold and collapse", () => { | ||
| 241 | 241 | expect(JSON.stringify(asksAnswer({ app, auth: { user: aliceUser } } as never, aliceUser))).not.toContain("is green"); |
| 242 | 242 | // Assigned to alice, the home's one list says it conflicts, never ready. |
| 243 | 243 | setAssignee(app.db, plums.id, aliceUser.id); |
| 244 | const home = await get("/"); | |
| 244 | const home = await get("/asks"); | |
| 245 | 245 | expect(home).toContain("conflicts with main"); |
| 246 | 246 | expect(askLinesFor(app.db, aliceUser).map((l) => l.waitingFor.word)).toEqual(["conflicts with main"]); |
| 247 | 247 | // Its own page still says why it cannot ship. |
test/asks-mute.test.ts +2 −1
| @@ -60,7 +60,7 @@ describe("muting a repository's asks", () => { | ||
| 60 | 60 | return ((await res.json()) as { rows: unknown[] }).rows; |
| 61 | 61 | }; |
| 62 | 62 | const home = async () => |
| 63 | await fetch(`${servers.url}/`, { headers: headers(cookie) }).then((r) => r.text()); | |
| 63 | await fetch(`${servers.url}/asks`, { headers: headers(cookie) }).then((r) => r.text()); | |
| 64 | 64 | |
| 65 | 65 | const signIn = async (username: string) => { |
| 66 | 66 | const res = await fetch(`${servers.url}/signin`, { |
| @@ -141,6 +141,7 @@ describe("muting a repository's asks", () => { | ||
| 141 | 141 | |
| 142 | 142 | const res = await post(cookie, "/asks/mute", [["repo", repo.id]]); |
| 143 | 143 | expect(res.status).toBe(303); |
| 144 | expect(res.headers.get("location")).toBe("/asks"); | |
| 144 | 145 | expect(mutedRepos(app.db, alice.id).has(repo.id)).toBe(true); |
| 145 | 146 | // Idempotent: twice is the same as once. |
| 146 | 147 | await post(cookie, "/asks/mute", [["repo", repo.id]]); |
test/asks-rank.test.ts +4 −4
| @@ -204,7 +204,7 @@ describe("asks that earn their place", () => { | ||
| 204 | 204 | expect(row.words).toEqual(ASK_WORDS.look); |
| 205 | 205 | expect(row.words.options.length).toBeGreaterThan(0); |
| 206 | 206 | } |
| 207 | const asks = await page("/"); | |
| 207 | const asks = await page("/asks"); | |
| 208 | 208 | expect(asks).toContain("If nobody acts, it waits; nothing ships."); |
| 209 | 209 | expect(asks).toContain("<b>security</b>"); |
| 210 | 210 | // The long sentence the card used to repeat is gone. |
| @@ -236,7 +236,7 @@ describe("asks that earn their place", () => { | ||
| 236 | 236 | for (const name of ["r4", "r5", "r6"]) |
| 237 | 237 | await greenPull(name, `Work in ${name}`); |
| 238 | 238 | // What needs you is pinned on the home (#634): count its own cards. |
| 239 | const home = await page("/"); | |
| 239 | const home = await page("/asks"); | |
| 240 | 240 | const asks = home.slice(home.indexOf('<section id="needs-you"')); |
| 241 | 241 | const cards = (s: string) => (s.match(/class="card[" ]/g) ?? []).length; |
| 242 | 242 | // Two before the phone's "more", three more inside it before the wide |
| @@ -340,7 +340,7 @@ describe("asks that earn their place", () => { | ||
| 340 | 340 | expect((await mcpAsks()).measure.raised).toBe(6); |
| 341 | 341 | }); |
| 342 | 342 | test("a phone's ask is a full-width row: kind and repo on one line, the decider apart, the rest behind More", async () => { |
| 343 | const home = await page("/"); | |
| 343 | const home = await page("/asks"); | |
| 344 | 344 | const asks = home.slice(home.indexOf('<section id="needs-you"')); |
| 345 | 345 | expect(asks).toContain("ask-card"); |
| 346 | 346 | expect(asks).toContain('<b class="ask-kind">'); |
| @@ -357,7 +357,7 @@ describe("asks that earn their place", () => { | ||
| 357 | 357 | // The security pull went red above; a new green one joins it in a row of two. |
| 358 | 358 | check(security, "ok"); |
| 359 | 359 | await greenPull("security", "Another change here"); |
| 360 | const asks = await page("/"); | |
| 360 | const asks = await page("/asks"); | |
| 361 | 361 | expect(asks).toContain("2 green changes wait for your look"); |
| 362 | 362 | expect(asks).toContain("<b>security</b>: \u201cWiden the board id\u201d"); |
| 363 | 363 | // On a phone the row is its head line, its two asks one tap away; a |
test/asks.test.ts +21 −15
| @@ -104,16 +104,22 @@ describe("the asks page", () => { | ||
| 104 | 104 | }); |
| 105 | 105 | |
| 106 | 106 | test("when nothing asks, it says All fine, and so does the header", async () => { |
| 107 | const page = await get(alice, "/"); | |
| 107 | const page = await get(alice, "/asks"); | |
| 108 | 108 | expect(sentence(page)).toBe("All fine"); |
| 109 | 109 | expect(headerCount(page)).toBe(0); |
| 110 | expect(await get(alice, "/")).not.toContain('<section id="needs-you"'); | |
| 110 | expect(await get(alice, "/asks")).not.toContain('<section id="needs-you"'); | |
| 111 | 111 | }); |
| 112 | 112 | |
| 113 | test("/asks lands on the home's pinned needs-you, and a signed-out reader there gets the door, no asks", async () => { | |
| 113 | test("/asks is its own page: the needs-you section in its live region, and a signed-out reader is sent to sign in, no asks", async () => { | |
| 114 | 114 | const response = await fetch(`${servers.url}/asks`, { redirect: "manual" }); |
| 115 | 115 | expect(response.status).toBe(303); |
| 116 | expect(response.headers.get("location")).toBe("/#needs-you"); | |
| 116 | expect(response.headers.get("location")).toContain("/signin"); | |
| 117 | const page = await get(alice, "/asks"); | |
| 118 | expect(page).toContain('id="live-on-you"'); | |
| 119 | expect(page).toContain('data-live-src="/live/on-you"'); | |
| 120 | expect(page).toContain("<title>"); | |
| 121 | expect(page).toContain('<h1 class="sr-only">Asks</h1>'); | |
| 122 | expect(page).toContain('id="asks-sentence"'); | |
| 117 | 123 | const door = await (await fetch(`${servers.url}/`)).text(); |
| 118 | 124 | expect(door).not.toContain('id="needs-you"'); |
| 119 | 125 | expect(door).not.toContain('id="asks-sentence"'); |
| @@ -146,7 +152,7 @@ describe("the asks page", () => { | ||
| 146 | 152 | claimRun(app.db, run.id); |
| 147 | 153 | finishRun(app.db, run.id, { state: "failed", summary: "red" }); |
| 148 | 154 | // Failed but the crew is still trying: that is working, not an ask. |
| 149 | expect(sentence(await get(alice, "/"))).toBe("All fine"); | |
| 155 | expect(sentence(await get(alice, "/asks"))).toBe("All fine"); | |
| 150 | 156 | recordDecision(app.db, { |
| 151 | 157 | kind: "retry", |
| 152 | 158 | repoId: repo.id, |
| @@ -157,16 +163,16 @@ describe("the asks page", () => { | ||
| 157 | 163 | by: "rule", |
| 158 | 164 | ms: 1, |
| 159 | 165 | }); |
| 160 | const page = await get(alice, "/"); | |
| 166 | const page = await get(alice, "/asks"); | |
| 161 | 167 | expect(sentence(page)).toBe("1 thing needs you"); |
| 162 | 168 | expect(page).toContain("Bot did a thing"); |
| 163 | 169 | expect(headerCount(page)).toBe(1); |
| 164 | 170 | // The home counts it in the header's Asks link; the dev dashboard's |
| 165 | 171 | // own "needs you" line went with the dashboard (#625). |
| 166 | const home = await get(alice, "/"); | |
| 172 | const home = await get(alice, "/asks"); | |
| 167 | 173 | expect(headerCount(home)).toBe(1); |
| 168 | 174 | // Bob can read the public repository but cannot write it: not his ask. |
| 169 | expect(sentence(await get(bob, "/"))).toBe("All fine"); | |
| 175 | expect(sentence(await get(bob, "/asks"))).toBe("All fine"); | |
| 170 | 176 | }); |
| 171 | 177 | |
| 172 | 178 | test("a forge health problem is a card for an admin and for no one else", async () => { |
| @@ -174,11 +180,11 @@ describe("the asks page", () => { | ||
| 174 | 180 | app.db |
| 175 | 181 | .query("UPDATE run SET created_at = ?1 WHERE state = 'queued'") |
| 176 | 182 | .run(new Date(Date.now() - 3 * 3_600_000).toISOString()); |
| 177 | const admin = await get(alice, "/"); | |
| 183 | const admin = await get(alice, "/asks"); | |
| 178 | 184 | expect(admin).toContain("behind one lane"); |
| 179 | 185 | expect(sentence(admin)).toBe("2 things need you"); |
| 180 | 186 | expect(headerCount(admin)).toBe(2); |
| 181 | const other = await get(bob, "/"); | |
| 187 | const other = await get(bob, "/asks"); | |
| 182 | 188 | expect(other).not.toContain("behind one lane"); |
| 183 | 189 | expect(sentence(other)).toBe("All fine"); |
| 184 | 190 | }); |
| @@ -224,17 +230,17 @@ describe("the asks page", () => { | ||
| 224 | 230 | ms: 1, |
| 225 | 231 | }); |
| 226 | 232 | const card = `id="ask-stopped:${pull.id}"`; |
| 227 | expect(await get(alice, "/")).toContain(card); | |
| 233 | expect(await get(alice, "/asks")).toContain(card); | |
| 228 | 234 | // The re-run passes: the stop is answered, and the pull is green. |
| 229 | 235 | check("ok"); |
| 230 | 236 | resolveOpen(app.db, pull.id, "retry", "green"); |
| 231 | const green = await get(alice, "/"); | |
| 237 | const green = await get(alice, "/asks"); | |
| 232 | 238 | expect(green).not.toContain(card); |
| 233 | 239 | expect(green).toContain(`id="ask-look:${pull.id}"`); |
| 234 | 240 | // Red again, before anything new is decided: the old stop is spent, and |
| 235 | 241 | // the crew's tries are what the card is counted from. |
| 236 | 242 | check("failed"); |
| 237 | expect(await get(alice, "/")).not.toContain(card); | |
| 243 | expect(await get(alice, "/asks")).not.toContain(card); | |
| 238 | 244 | }); |
| 239 | 245 | |
| 240 | 246 | test("a green change's card says what its tests did", async () => { |
| @@ -309,11 +315,11 @@ describe("the asks page", () => { | ||
| 309 | 315 | expect(card.says).toContain("alice"); |
| 310 | 316 | expect(card.says).toContain("checks.concurrency"); |
| 311 | 317 | expect(ASK_WORDS.setting.options).toEqual(["Keep", "Put back"]); |
| 312 | const page = await get(alice, "/"); | |
| 318 | const page = await get(alice, "/asks"); | |
| 313 | 319 | expect(page).toContain("Keep"); |
| 314 | 320 | expect(page).toContain("Put back"); |
| 315 | 321 | // not for a person who cannot put it back |
| 316 | expect(await get(bob, "/")).not.toContain("Put back"); | |
| 322 | expect(await get(bob, "/asks")).not.toContain("Put back"); | |
| 317 | 323 | expect(listEvents(app.db, { kind: "issue.assigned" }).length).toBe(0); |
| 318 | 324 | }); |
| 319 | 325 |
test/holds.test.ts +1 −1
| @@ -741,7 +741,7 @@ describe("a hold is set, shown and lifted", () => { | ||
| 741 | 741 | // What needs you, pinned on the home (#634), is the part to read: the |
| 742 | 742 | // feed below it may well mention the change as news. |
| 743 | 743 | const asks = async (cookie: string) => { |
| 744 | const home = await page("/", cookie); | |
| 744 | const home = await page("/asks", cookie); | |
| 745 | 745 | const at = home.indexOf('<section id="needs-you"'); |
| 746 | 746 | return at === -1 ? "" : home.slice(at, home.indexOf("</section>", at)); |
| 747 | 747 | }; |
test/planner-declined.test.ts +1 −1
| @@ -183,7 +183,7 @@ describe("the planner declined, waiting for a person", () => { | ||
| 183 | 183 | expect(board).toContain( |
| 184 | 184 | `action="/alice/garden/issues/${direction.number}/comments"`, |
| 185 | 185 | ); |
| 186 | const asks = await page(aliceCookie, "/"); | |
| 186 | const asks = await page(aliceCookie, "/asks"); | |
| 187 | 187 | expect(asks).toContain("The planner declined the direction"); |
| 188 | 188 | expect(asks).toContain( |
| 189 | 189 | `/alice/garden/issues/${direction.number}#comment-body`, |
test/review-wait.test.ts +1 −1
| @@ -34,7 +34,7 @@ describe("a green agent pull and its reviewer", () => { | ||
| 34 | 34 | |
| 35 | 35 | const home = async (): Promise<string> => |
| 36 | 36 | ( |
| 37 | await (await fetch(`${servers.url}/`, { headers: { cookie } })).text() | |
| 37 | await (await fetch(`${servers.url}/asks`, { headers: { cookie } })).text() | |
| 38 | 38 | ).replace(/\s+/g, " "); |
| 39 | 39 | const wordCtx = (): WordCtx => ({ db: app.db }) as unknown as WordCtx; |
| 40 | 40 |
test/web-apps.test.ts +3 −3
| @@ -824,11 +824,11 @@ describe("apps, runs and the crew on the web", () => { | ||
| 824 | 824 | form: { assignee: "alice" }, |
| 825 | 825 | }); |
| 826 | 826 | expect(assigned.status).toBe(303); |
| 827 | const dash = await bodyOf(alice, "/"); | |
| 827 | const dash = await bodyOf(alice, "/asks"); | |
| 828 | 828 | expect(dash).toContain('<section id="needs-you"'); |
| 829 | 829 | expect(dash).toMatch(/<h2 id="asks-sentence">\d+ things? needs? you<\/h2>/); |
| 830 | 830 | expect(dash).toContain("Say hello"); |
| 831 | expect(await bodyOf(bob, "/")).not.toContain('<section id="needs-you"'); | |
| 831 | expect(await bodyOf(bob, "/asks")).not.toContain('<section id="needs-you"'); | |
| 832 | 832 | |
| 833 | 833 | // Needs you: a pull the crew opened, in a repository alice can write. |
| 834 | 834 | expect(addToCrew(app, aliceUser, hello, "bot").ok).toBe(true); |
| @@ -849,7 +849,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 849 | 849 | // racing to finish here: when it does, the dashboard links to /asks |
| 850 | 850 | // rather than listing it (test/asks.test.ts holds that page to account). |
| 851 | 851 | // Bob can read the public repo but not write it: not his inbox. |
| 852 | expect(await bodyOf(bob, "/")).not.toContain('<section id="needs-you"'); | |
| 852 | expect(await bodyOf(bob, "/asks")).not.toContain('<section id="needs-you"'); | |
| 853 | 853 | }); |
| 854 | 854 | |
| 855 | 855 | test("the assignee select offers the people who can read the repository", async () => { |
new above
offline
The asks heading is sr-only on a page whose only content is a card list. That is fine for screen readers, but check on the phone screenshot that the sentence (asks-sentence) is the visible heading and not duplicated.