Public: anyone can read this; the crew can change it.

The teams list is keyboard-first: j/k move between rows, enter opens one #10368

Merged opened this

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

Merged

scout/forge:scout/10356-the-teams-list-is-keyboard-first-j-k-mov → main 9 commits fork 6 threads · 6 unresolved

  • Merged. scout/10356-the-teams-list-is-keyboard-first-j-k-mov was merged into main as 216c1f8.

Review

Sign in to review this change.
7 files changed +239 −63
src/http/settings_routes.ts +2 −1
@@ -12,7 +12,7 @@ import type { Router } from "./router.ts";
1212 import { parentLinkStart } from "./rp.ts";
1313 import { accessPage } from "../web/pages/access.ts";
1414 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
15 import { teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts";
15 import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts";
1616 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
1717 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
1818 import { economicsPage, economicsSet } from "../web/pages/economics.ts";
@@ -250,6 +250,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
250250 router.add("POST", "/settings/crew", agentCreate);
251251 router.add("GET", "/settings/teams", teamsPage);
252252 router.add("GET", "/settings/teams/stream", teamsStream);
253 router.add("GET", "/settings/teams/:slug", teamPage);
253254 router.add("POST", "/settings/teams/:slug", teamSet);
254255 router.add("GET", "/settings/crew/:username", agentPage);
255256 router.add("POST", "/settings/crew/:username", agentUpdate);
src/web/pages/teams.ts +89 −50
@@ -1,11 +1,14 @@
11 /**
22 * Crew teams, for a forge admin (`core/teams.ts`): each team's charter, its
3 * dial and who is on it, set on one page linked from the crew page. Anyone
4 * else gets the not-found page, before a body is read.
3 * dial and who is on it. The list at /settings/teams is one j/k row per team;
4 * Enter opens the team's own page, where it is set. Anyone else gets the
5 * not-found page, before a body is read.
56 */
67
78 import { changeTeam, readTeamQueue, readTeams, teamChanges, TEAM_SAYS } from "../../actions/teams.ts";
8 import { CHARTER_MAX, DIAL_MAX } from "../../core/teams.ts";
9 import type { Agent } from "../../core/agents.ts";
10 import { CHARTER_MAX, DIAL_MAX, type Team } from "../../core/teams.ts";
11 import type { QueuedPull } from "../../core/team_queue.ts";
912 import { timeAgo } from "../ui.ts";
1013 import type { Ctx } from "../../http/context.ts";
1114 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
@@ -14,7 +17,7 @@ import { field, readForm } from "../form.ts";
1417 import { eventStream } from "../../http/sse.ts";
1518 import { html, raw, type Html } from "../html.ts";
1619 import { flashTo, page } from "../layout.ts";
17 import { btn, icon } from "../ui.ts";
20 import { btn, icon, row } from "../ui.ts";
1821 import { accountSections } from "./origins.ts";
1922
2023 const isAdminPerson = (ctx: Ctx): boolean =>
@@ -26,7 +29,7 @@ function regions(ctx: Ctx, timed = false): Record<string, string> | undefined {
2629 timed ? timePhase(ctx.request, name, work) : work();
2730 const read = phase("teams", () => readTeams(ctx.app, ctx.auth.user));
2831 if (!read.ok) return undefined;
29 const { teams, agents } = read.value;
32 const { teams } = read.value;
3033 const changes = phase("changes", () => teamChanges(ctx.app, 10));
3134 const total = teams.reduce((n, t) => n + t.dial, 0);
3235 const queues = phase("queue", () =>
@@ -36,50 +39,18 @@ function regions(ctx: Ctx, timed = false): Record<string, string> | undefined {
3639 })),
3740 );
outdated

Comment explains the deviation; put it in the pull, not the code.

3841 const cards = teams.map((t) => {
outdated

Principle 7: cards with forms, not rows. Also the id plus self-anchor link means Enter goes nowhere new; give the heading a real destination.

39 const waiting = queues.get(t.slug) ?? [];
40 return html`<section>
41 <h2>${icon("bot")} ${t.name}</h2>
42 ${waiting.length === 0
43 ? html`<p class="muted small">Nothing is waiting on this team.</p>`
44 : html`<p class="muted small">Waiting on this team, oldest first</p>
45 <dl class="status-rows">${waiting.map(
46 (w) => html`<div class="status-row">
47 <dt><a href="/${w.owner}/${w.repo}/pulls/${String(w.number)}">${w.owner}/${w.repo} #${String(w.number)}</a></dt>
48 <dd>${w.title} <span class="muted">${timeAgo(w.createdAt)}</span></dd>
49 </div>`,
50 )}</dl>`}
51 <form method="post" action="/settings/teams/${t.slug}">
52 <div class="field">
53 <label for="charter-${t.slug}">Charter</label>
54 <textarea class="input" id="charter-${t.slug}" name="charter" rows="3" maxlength="${String(CHARTER_MAX)}">${t.charter}</textarea>
55 <span class="hint">What it owns, what it reviews.</span>
56 </div>
57 <div class="field">
58 <label for="dial-${t.slug}">Dial (%)</label>
59 <input class="input mono" id="dial-${t.slug}" type="number" name="dial" min="0" max="${String(DIAL_MAX)}" step="1" value="${String(t.dial)}" />
60 <span class="hint">Its share of builder slots and budget.</span>
61 </div>
62 <fieldset class="field">
63 <legend>Members</legend>
64 ${agents.length === 0
65 ? html`<span class="muted small">No agents yet.</span>`
66 : agents.map(
67 (a) => html`<label class="small">
68 <input type="checkbox" name="member" value="${a.user.id}" ${t.members.includes(a.user.id) ? "checked" : ""} />
69 ${a.user.username}
70 <span class="muted mono">${a.model === "" ? "default" : a.model}</span>
71 </label><br />`,
72 )}
73 </fieldset>
74 <div class="form-actions">
75 ${btn("Save", { variant: "primary", label: `Save ${t.name}` })}
76 </div>
77 </form>
78 </section>`;
42 const waiting = queues.get(t.slug)?.length ?? 0;
outdated

Principle 1: a heading link to its own anchor says nothing twice and does nothing.

43 return row({
44 nav: true,
45 href: `/settings/teams/${t.slug}`,
46 lead: icon("bot"),
47 title: t.name,
48 meta: html`${String(t.dial)}% · ${String(t.members.length)} ${t.members.length === 1 ? "member" : "members"} · ${String(waiting)} waiting`,
49 });
7950 });
8051 const out: Record<string, Html> = {
8152 "teams-total": html`${String(total)}%`,
82 "teams-cards": html`${cards}`,
53 "teams-cards": cards.length === 0 ? html`<p class="muted small">No teams yet.</p>` : html`<ul class="list">${cards}</ul>`,
8354 "teams-changes":
8455 changes.length === 0
8556 ? html``
@@ -93,10 +64,74 @@ function regions(ctx: Ctx, timed = false): Record<string, string> | undefined {
9364 return Object.fromEntries(Object.entries(out).map(([k, h]) => [k, h.text]));
9465 }
9566
67 /** One team's page: who is waiting on it, and the form that sets its charter, dial and members. */
68 function teamBody(t: Team, agents: readonly Agent[], waiting: readonly QueuedPull[]): Html {
69 return html`${waiting.length === 0
70 ? html`<p class="muted small">Nothing is waiting on this team.</p>`
71 : html`<p class="muted small">Waiting on this team, oldest first</p>
72 <dl class="status-rows">${waiting.map(
73 (w) => html`<div class="status-row">
74 <dt><a href="/${w.owner}/${w.repo}/pulls/${String(w.number)}">${w.owner}/${w.repo} #${String(w.number)}</a></dt>
75 <dd>${w.title} <span class="muted">${timeAgo(w.createdAt)}</span></dd>
76 </div>`,
77 )}</dl>`}
78 <form method="post" action="/settings/teams/${t.slug}">
79 <div class="field">
80 <label for="charter-${t.slug}">Charter</label>
81 <textarea class="input" id="charter-${t.slug}" name="charter" rows="3" maxlength="${String(CHARTER_MAX)}">${t.charter}</textarea>
82 <span class="hint">What it owns, what it reviews.</span>
83 </div>
84 <div class="field">
85 <label for="dial-${t.slug}">Dial (%)</label>
86 <input class="input mono" id="dial-${t.slug}" type="number" name="dial" min="0" max="${String(DIAL_MAX)}" step="1" value="${String(t.dial)}" />
87 <span class="hint">Its share of builder slots and budget.</span>
88 </div>
89 <fieldset class="field">
90 <legend>Members</legend>
91 ${agents.length === 0
92 ? html`<span class="muted small">No agents yet.</span>`
93 : agents.map(
94 (a) => html`<label class="small">
95 <input type="checkbox" name="member" value="${a.user.id}" ${t.members.includes(a.user.id) ? "checked" : ""} />
96 ${a.user.username}
97 <span class="muted mono">${a.model === "" ? "default" : a.model}</span>
98 </label><br />`,
99 )}
100 </fieldset>
101 <div class="form-actions">
102 ${btn("Save", { variant: "primary", label: `Save ${t.name}` })}
103 </div>
104 </form>`;
105 }
106
107 // GET /settings/teams/:slug
108 export function teamPage(ctx: Ctx): Response {
109 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
110 const read = timePhase(ctx.request, "teams", () => readTeams(ctx.app, ctx.auth.user));
111 if (!read.ok) return notFoundPage(ctx);
112 const team = read.value.teams.find((t) => t.slug === (ctx.params.slug ?? ""));
113 if (!team) return notFoundPage(ctx);
114 const queue = timePhase(ctx.request, "queue", () => readTeamQueue(ctx.app, ctx.auth.user, team.slug));
115 const rendering = performance.now();
116 const out = page({
117 ctx,
118 title: team.name,
119 body: html`<div class="page-head"><h1>${team.name}</h1></div>
120 ${settingsLayout(
121 accountSections("crew", { admin: true }),
122 html`<p class="muted small">${btn("All teams", { href: "/settings/teams", variant: "ghost" })}</p>
123 ${teamBody(team, read.value.agents, queue.ok ? queue.value : [])}`,
124 "Account settings",
125 )}`,
126 });
127 notePhase(ctx.request, "render", performance.now() - rendering);
128 return out;
129 }
130
96131 /**
97132 * The client: one EventSource; a pushed region replaces the element of its
98 * id. The cards are left alone while one of their fields has focus, so a
99 * viewer's half-typed edit is never lost. Our own server's escaped markup.
133 * id. The list is redrawn whole, so the focused row (found by its link) gets
134 * focus back. Our own server's escaped markup.
100135 */
101136 export const teamsLiveJs = /* js */ `
102137 (function () {
@@ -110,8 +145,12 @@ export const teamsLiveJs = /* js */ `
110145 for (var id in regions) {
111146 var el = document.getElementById(id);
112147 if (!el || el.innerHTML === regions[id]) continue;
113 if (id === 'teams-cards' && el.contains(document.activeElement)) continue;
148 var a = document.activeElement, kept = a && el.contains(a) ? a.getAttribute('href') : null;
114149 el.innerHTML = regions[id];
150 if (kept) {
151 var again = el.querySelectorAll('a[href]');
152 for (var i = 0; i < again.length; i++) if (again[i].getAttribute('href') === kept) { again[i].focus(); break; }
153 }
115154 }
116155 });
117156 })();
@@ -175,7 +214,7 @@ export async function teamSet(ctx: Ctx): Promise<Response> {
175214 if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx);
176215 return flashTo(
177216 ctx,
178 "/settings/teams",
217 `/settings/teams/${slug}`,
179218 changed.ok
180219 ? { message: `${changed.value.name} saved at ${changed.value.dial}%.` }
181220 : { tone: "danger", message: TEAM_SAYS[changed.error] },
test/crew-teams.test.ts +1 −1
@@ -73,7 +73,7 @@ describe("crew teams", () => {
7373 const res = await post(alice, "design", new URLSearchParams({ charter: "Owns <b>looks</b>", dial: "30" }));
7474 expect(res.status).toBe(303);
7575 expect(dialOf("design")).toBe(30);
76 const text = await (await fetch(`${servers.url}/settings/teams`, { headers: { cookie: cookie(alice) } })).text();
76 const text = await (await fetch(`${servers.url}/settings/teams/design`, { headers: { cookie: cookie(alice) } })).text();
7777 expect(text).toContain('value="30"');
7878 expect(text).toContain("Owns &lt;b&gt;looks");
7979 expect(text).not.toContain("Owns <b>looks");
test/keys-teams-list.test.ts added +119
@@ -0,0 +1,119 @@
1 /** The teams list joins the shell's j/k row traversal: one data-nav row per team, each linking to the team's own page. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { listTeams } from "../src/core/teams.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { seedAdmin } from "./helpers/admin.ts";
10 import { teamsLiveJs } from "../src/web/pages/teams.ts";
11
12 let dataDir = "";
13 let app: App;
14 let servers: Servers;
15 let bearer = "";
16
17 const get = async (path = "/settings/teams"): Promise<Response> =>
18 fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${bearer}` } });
19
20 beforeAll(async () => {
21 dataDir = await mkdtemp(join(tmpdir(), "forge-keys-teams-"));
22 app = createApp({ dataDir, port: 0 });
23 servers = await serve(app);
24 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
25 const minted = await fetch(`${servers.url}/api/v1/tokens`, {
26 method: "POST",
27 headers: { "content-type": "application/json" },
28 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
29 });
30 bearer = ((await minted.json()) as { token: string }).token;
31 });
32
33 afterAll(async () => {
34 servers.stop();
35 app.close();
36 await rm(dataDir, { recursive: true, force: true });
37 });
38
39 test("each team is one data-nav row whose link is that team's own page, and that page answers", async () => {
40 const slugs = listTeams(app.db).map((t) => t.slug);
41 expect(slugs.length).toBeGreaterThan(0);
42 const text = await (await get()).text();
43 expect(text.match(/data-nav tabindex="-1"/g)!.length).toBe(slugs.length);
44 for (const s of slugs) {
45 expect(text).toMatch(new RegExp(`<a href="/settings/teams/${s}"[^>]*data-nav tabindex="-1"`));
46 const res = await get(`/settings/teams/${s}`);
47 expect(res.status).toBe(200);
48 expect(await res.text()).toContain(`action="/settings/teams/${s}"`);
49 }
50 expect((await get("/settings/teams/no-such-team")).status).toBe(404);
51 });
52
outdated

Asserting no tabindex=-1 anywhere is brittle; assert no data-nav instead.

This only checks that the script text contains getAttribute(href) and .focus(); it would pass with a broken restore. Execute teamsLiveJs against a small DOM stub (a list with two links, focus on the second, apply a region message, assert the matching link has focus), so it fails without the change.

53 /** Runs the page's live script against a minimal DOM: the region is redrawn and the row that had focus gets it back. */
54 function runLive(): { region: { innerHTML: string }; push: (html: string) => void; focusHref: (h: string) => void; focused: () => string | null } {
55 type Link = { href: string; getAttribute: (n: string) => string | null; focus: () => void };
56 let active: Link | null = null;
57 let links: Link[] = [];
58 let inner = "";
59 const region = {
60 get innerHTML() { return inner; },
61 set innerHTML(v: string) {
outdated

Box 2 asks for none data-nav on the empty page, but this asserts only an absent href. Assert the page data-nav count equals the shell baseline (count on another admin page like /settings/capacity), so a stray row would fail.

62 inner = v;
63 links = [...v.matchAll(/<a href="([^"]+)"/g)].map((m) => {
64 const link: Link = { href: m[1]!, getAttribute: (n) => (n === "href" ? m[1]! : null), focus: () => { active = link; } };
65 return link;
66 });
67 if (active && !links.includes(active)) active = null; // the old node is gone, as in a browser
68 },
69 contains: (node: unknown) => links.includes(node as Link),
70 querySelectorAll: () => links,
71 };
72 let listener: ((e: { data: string }) => void) | null = null;
73 class FakeEventSource {
74 constructor(_url: string) {}
75 addEventListener(_kind: string, fn: (e: { data: string }) => void) { listener = fn; }
76 }
77 const document = {
78 getElementById: (id: string) => (id === "teams-cards" ? region : null),
79 get activeElement() { return active; },
80 };
81 new Function("EventSource", "document", teamsLiveJs)(FakeEventSource, document);
82 return {
83 region,
84 push: (html) => listener!({ data: JSON.stringify({ regions: { "teams-cards": html } }) }),
85 focusHref: (h) => links.find((l) => l.href === h)!.focus(),
86 focused: () => (active ? (active as Link).href : null),
87 };
88 }
89
90 const rows = (extra = "") =>
91 `<ul><li><a href="/settings/teams/a" data-nav tabindex="-1">a</a></li><li><a href="/settings/teams/b" data-nav tabindex="-1">b${extra}</a></li></ul>`;
92
93 test("a live redraw gives focus back to the row that had it", () => {
94 const live = runLive();
95 live.region.innerHTML = rows();
96 live.focusHref("/settings/teams/b");
97 live.push(rows(" (changed)"));
98 expect(live.region.innerHTML).toContain("(changed)");
99 expect(live.focused()).toBe("/settings/teams/b");
100 });
101
102 test("a live redraw does not steal focus when none of its rows had it", () => {
103 const live = runLive();
104 live.region.innerHTML = rows();
105 live.push(rows(" (changed)"));
106 expect(live.focused()).toBeNull();
107 });
108
109 // Last: it deletes every team.
110 test("with no teams the list draws no data-nav", async () => {
111 app.db.query("DELETE FROM crew_team_member").run();
112 app.db.query("DELETE FROM crew_team").run();
113 const navs = (t: string) => (t.match(/\bdata-nav\b/g) ?? []).length;
114 const baseline = navs(await (await get("/settings/capacity")).text());
115 const text = await (await get()).text();
116 expect(navs(text)).toBe(baseline);
117 expect(text).not.toContain('href="/settings/teams/');
118 expect(text).toContain("No teams yet.");
119 });
test/settings-routes.test.ts +1
@@ -101,6 +101,7 @@ GET /settings/crew
101101 POST /settings/crew
102102 GET /settings/teams
103103 GET /settings/teams/stream
104 GET /settings/teams/:slug
104105 POST /settings/teams/:slug
105106 GET /settings/crew/:username
106107 POST /settings/crew/:username
test/team-queue.test.ts +5 −2
@@ -113,7 +113,7 @@ describe("pulls waiting on a team", () => {
113113 test("the web page escapes the title, links the pull and says so when a team has nothing", async () => {
114114 const first = await pullNaming("<b>First</b>", ["design"]);
115115 servers = await serve(app);
116 const res = await fetch(`${servers.url}/settings/teams`, {
116 const res = await fetch(`${servers.url}/settings/teams/design`, {
117117 headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` },
118118 });
119119 const body = await res.text();
@@ -122,7 +122,10 @@ describe("pulls waiting on a team", () => {
122122 expect(body).toContain("&lt;b&gt;First&lt;/b&gt;");
123123 expect(body).not.toContain("<b>First</b>");
124124 expect(body).toContain(`/alice/garden/pulls/${first.number}`);
125 expect(body).toContain("Nothing is waiting on this team.");
125 const empty = await fetch(`${servers.url}/settings/teams/security`, {
126 headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` },
127 });
128 expect(await empty.text()).toContain("Nothing is waiting on this team.");
126129 expect(body).not.toContain("<h3>Waiting on this team");
127130 });
128131
test/teams-language.test.ts +22 −9
@@ -1,4 +1,4 @@
1 /** Settings › Teams speaks the home page's language: one heading, a section per team, a plain list; no card. */
1 /** Settings › Teams speaks the home page's language: one heading, a row per team, its own page with the forms; no card. */
22 import { afterAll, beforeAll, expect, test } from "bun:test";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
@@ -46,22 +46,33 @@ const teamsMain = async (): Promise<string> => {
4646 return page.slice(page.indexOf("<main"), page.indexOf("</main>"));
4747 };
4848
49 test("one heading, a section per team with its forms, no card", async () => {
49 const teamMain = async (slug: string): Promise<string> => {
50 const res = await fetch(`${servers.url}/settings/teams/${slug}`, { headers: { cookie }, redirect: "manual" });
51 expect(res.status).toBe(200);
52 const page = await res.text();
53 return page.slice(page.indexOf("<main"), page.indexOf("</main>"));
54 };
55
56 test("the list is one heading and a row per team; each team's page holds its form, no card", async () => {
5057 const main = await teamsMain();
5158 expect(main.match(/<h1[\s>]/g)?.length).toBe(1);
5259 expect(main).not.toContain('class="card');
53 expect(main).not.toContain("card-body");
54 expect(main).toContain("<section>");
55 expect(main).toMatch(/<h2>[^]*?<\/h2>/);
56 expect(main).toMatch(/action="\/settings\/teams\/[a-z0-9-]+"/);
60 expect(main).toContain('<ul class="list">');
61 expect(main).not.toContain("<form");
5762 expect(main).toMatch(/id="teams-total">\d+%</);
63 const slug = /href="\/settings\/teams\/([a-z0-9-]+)"/.exec(main)?.[1] ?? "";
64 expect(slug).not.toBe("");
65 const team = await teamMain(slug);
66 expect(team.match(/<h1[\s>]/g)?.length).toBe(1);
67 expect(team).not.toContain('class="card');
68 expect(team).toContain(`action="/settings/teams/${slug}"`);
5869 });
5970
6071 test("one POST saves; Recent changes is then a plain h2 and ul, and the total is still a percentage", async () => {
6172 const before = await teamsMain();
6273 expect(before).not.toContain("Recent changes");
63 const slug = /action="\/settings\/teams\/([a-z0-9-]+)"/.exec(before)?.[1] ?? "";
64 const dial = new RegExp(`id="dial-${slug}"[^>]*value="(\\d+)"`).exec(before)?.[1] ?? "0";
74 const slug = /href="\/settings\/teams\/([a-z0-9-]+)"/.exec(before)?.[1] ?? "";
75 const dial = new RegExp(`id="dial-${slug}"[^>]*value="(\\d+)"`).exec(await teamMain(slug))?.[1] ?? "0";
6576 const post = await fetch(`${servers.url}/settings/teams/${slug}`, {
6677 method: "POST",
6778 headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" },
@@ -69,8 +80,8 @@ test("one POST saves; Recent changes is then a plain h2 and ul, and the total is
6980 redirect: "manual",
7081 });
7182 expect(post.status).toBeLessThan(400);
83 expect(await teamMain(slug)).toContain("Ship small things.");
7284 const main = await teamsMain();
73 expect(main).toContain("Ship small things.");
7485 expect(main).toMatch(/<h2>Recent changes<\/h2>\s*<ul[^>]*>\s*<li>/);
7586 const section = main.slice(main.indexOf("<h2>Recent changes</h2>"));
7687 expect(section).not.toContain("card");
@@ -80,6 +91,8 @@ test("one POST saves; Recent changes is then a plain h2 and ul, and the total is
8091 test("a non-admin still gets 404, on page and post", async () => {
8192 const res = await fetch(`${servers.url}/settings/teams`, { headers: { cookie: other }, redirect: "manual" });
8293 expect(res.status).toBe(404);
94 const own = await fetch(`${servers.url}/settings/teams/design`, { headers: { cookie: other }, redirect: "manual" });
95 expect(own.status).toBe(404);
8396 const post = await fetch(`${servers.url}/settings/teams/x`, { method: "POST", headers: { cookie: other }, redirect: "manual" });
8497 expect(post.status).toBe(404);
8598 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs