Public: anyone can read this; the crew can change it.
/migrate and a migration's job page speak the home page's language #9827
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9814-migrate-and-a-migration-s-job-page-speak → main 2 commits fork
2 files
changed +117
−22
src/web/pages/migrate.ts +14 −22
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | * commits and selects the one the app most likely lives on — the person's |
| 14 | 14 | * newest (`origins/work-branch.ts`); the Migrate button carries the choice. |
| 15 | 15 | * |
| 16 | * An account whose token sees public repositories only says so on its card | |
| 16 | * An account whose token sees public repositories only says so under its account | |
| 17 | 17 | * (`origins/token-reach.ts`), with GitHub's new-token page one click away |
| 18 | 18 | * and a field that replaces the token in place. |
| 19 | 19 | * |
| @@ -55,7 +55,6 @@ import { flashTo, page, redirect } from "../layout.ts"; | ||
| 55 | 55 | import { |
| 56 | 56 | badge, |
| 57 | 57 | btn, |
| 58 | card, | |
| 59 | 58 | icon, |
| 60 | 59 | row, |
| 61 | 60 | selectField, |
| @@ -295,8 +294,9 @@ function accounts(ctx: Ctx, origins: readonly OriginChoices[]): Html { | ||
| 295 | 294 | <a href="/settings/origins">Settings → Origins</a>; a token pasted |
| 296 | 295 | there works until then. |
| 297 | 296 | </p>`; |
| 298 | return card( | |
| 299 | html`${origins.length === 0 | |
| 297 | return html`<section class="mb" id="migrate-accounts-section"> | |
| 298 | <h2 class="mb">Your accounts</h2> | |
| 299 | ${origins.length === 0 | |
| 300 | 300 | ? null |
| 301 | 301 | : html`<ul class="list" id="migrate-accounts"> |
| 302 | 302 | ${origins.map((origin) => |
| @@ -315,12 +315,8 @@ function accounts(ctx: Ctx, origins: readonly OriginChoices[]): Html { | ||
| 315 | 315 | )} |
| 316 | 316 | </ul>`} |
| 317 | 317 | ${origins.filter((o) => o.reach === "public").map(publicOnly)} |
| 318 | ${connect}`, | |
| 319 | { | |
| 320 | head: html`${icon("globe")} | |
| 321 | <h2>Your accounts</h2>`, | |
| 322 | }, | |
| 323 | ); | |
| 318 | ${connect} | |
| 319 | </section>`; | |
| 324 | 320 | } |
| 325 | 321 | |
| 326 | 322 | /** |
| @@ -457,9 +453,8 @@ function migrateForm( | ||
| 457 | 453 | </p>`} |
| 458 | 454 | ${accounts(ctx, origins)} |
| 459 | 455 | <form method="get" action="/migrate" id="migrate-search"></form> |
| 460 | ${card( | |
| 461 | html`<form method="post" action="/migrate"> | |
| 462 | <h3 class="mb">Source</h3> | |
| 456 | <form method="post" action="/migrate"> | |
| 457 | <h2 class="mb">Source</h2> | |
| 463 | 458 | ${picker(origins, values, crew.length > 0, branches)} |
| 464 | 459 | ${formField({ |
| 465 | 460 | label: origins.every((o) => o.repos.length === 0) |
| @@ -473,7 +468,7 @@ function migrateForm( | ||
| 473 | 468 | autofocus: origins.every((o) => o.repos.length === 0), |
| 474 | 469 | hint: "Any public repository over https, http or git. It is cloned anonymously.", |
| 475 | 470 | })} |
| 476 | <h3 class="mb mt">Destination</h3> | |
| 471 | <h2 class="mb mt">Destination</h2> | |
| 477 | 472 | ${selectField({ |
| 478 | 473 | label: "Owner", |
| 479 | 474 | name: "owner", |
| @@ -507,7 +502,7 @@ function migrateForm( | ||
| 507 | 502 | }, |
| 508 | 503 | ], |
| 509 | 504 | })} |
| 510 | <h3 class="mb mt">Put a crew on it</h3> | |
| 505 | <h2 class="mb mt">Put a crew on it</h2> | |
| 511 | 506 | ${crew.length === 0 |
| 512 | 507 | ? html`<p class="muted small mb"> |
| 513 | 508 | ${icon("bot")} No agents on this forge yet, so there is no crew to |
| @@ -550,7 +545,7 @@ function migrateForm( | ||
| 550 | 545 | > |
| 551 | 546 | </label> |
| 552 | 547 | </div>`} |
| 553 | <h3 class="mb mt">What happens to the GitHub repository</h3> | |
| 548 | <h2 class="mb mt">What happens to the GitHub repository</h2> | |
| 554 | 549 | ${selectField({ |
| 555 | 550 | label: "Afterwards", |
| 556 | 551 | name: "after", |
| @@ -577,8 +572,7 @@ function migrateForm( | ||
| 577 | 572 | ${btn("Migrate", { variant: "primary", icon: "download" })} |
| 578 | 573 | ${btn("Cancel", { href: "/", variant: "ghost" })} |
| 579 | 574 | </div> |
| 580 | </form>`, | |
| 581 | )} | |
| 575 | </form> | |
| 582 | 576 | </div>`; |
| 583 | 577 | } |
| 584 | 578 | |
| @@ -851,10 +845,8 @@ export function migrationJobPage(ctx: Ctx): Response { | ||
| 851 | 845 | ...(failed ? {} : { scriptJs: migrateJobJs }), |
| 852 | 846 | body: html`${pageHead(`Migrating ${job.fullName}`)} |
| 853 | 847 | <div class="narrow-wide" id="migrate-job" data-job="${job.id}"> |
| 854 | ${card(stageRows(job), { | |
| 855 | head: html`${icon("download")} | |
| 856 | <h2>${job.fullName}</h2>`, | |
| 857 | })} | |
| 848 | <h2 class="mb">${job.fullName}</h2> | |
| 849 | ${stageRows(job)} | |
| 858 | 850 | ${failed |
| 859 | 851 | ? html`<div class="field" data-invalid> |
| 860 | 852 | <span class="error" role="alert" id="migrate-job-err" |
test/migrate-language.test.ts added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | /** /migrate and a migration's job page speak the home page's language (#9814): headings and rows, no card. */ | |
| 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 } from "../src/app.ts"; | |
| 7 | import { connectOrigin } from "../src/actions/origins.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { serve } from "../src/http/server.ts"; | |
| 11 | import { advance, createJob } from "../src/origins/jobs.ts"; | |
| 12 | import { forgetProviders } from "../src/origins/providers/index.ts"; | |
| 13 | import { newFakeState, serveFakeForge } from "./fake-origin.ts"; | |
| 14 | import { promote } from "./helpers/members.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let dir = ""; | |
| 20 | let app: ReturnType<typeof createApp>; | |
| 21 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 22 | let cookie = ""; | |
| 23 | let other = ""; | |
| 24 | let alice: User; | |
| 25 | const person = async (username: string): Promise<User> => { | |
| 26 | const made = await createUser(app.db, { | |
| 27 | origin: "seed", | |
| 28 | trusted: true, | |
| 29 | username, | |
| 30 | email: `${username}@forge.test`, | |
| 31 | adminIfFirst: false, | |
| 32 | }); | |
| 33 | if (!made.ok) throw new Error(String(made.error)); | |
| 34 | promote(app.db, made.value); | |
| 35 | return made.value; | |
| 36 | }; | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dir = await mkdtemp(join(tmpdir(), "forge-migrate-lang-")); | |
| 40 | app = createApp({ dataDir: dir, port: 0 }); | |
| 41 | servers = await serve(app); | |
| 42 | alice = await person("alice"); | |
| 43 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 44 | const bob = await person("bob"); | |
| 45 | other = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 46 | }); | |
| 47 | afterAll(async () => { | |
| 48 | servers.stop(); | |
| 49 | app.close(); | |
| 50 | await rm(dir, { recursive: true, force: true }); | |
| 51 | }); | |
| 52 | ||
| 53 | const main = (page: string): string => page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 54 | const get = async (path: string, who = cookie): Promise<string> => { | |
| 55 | const res = await fetch(servers.url + path, { headers: { cookie: who }, redirect: "manual" }); | |
| 56 | expect(res.status).toBe(200); | |
| 57 | return main(await res.text()); | |
| 58 | }; | |
| 59 | const noCard = (page: string): void => { | |
| 60 | expect(page).not.toContain('class="card'); | |
| 61 | expect(page).not.toContain("card-body"); | |
| 62 | expect(page).toContain("<h2"); | |
| 63 | }; | |
| 64 | ||
| 65 | test("/migrate with no account: headings, the URL field and Migrate, no card", async () => { | |
| 66 | const page = await get("/migrate", other); | |
| 67 | noCard(page); | |
| 68 | expect(page).toContain("Your accounts"); | |
| 69 | expect(page).toContain("Migrate"); | |
| 70 | expect(page).toContain('name="url"'); | |
| 71 | }); | |
| 72 | ||
| 73 | test("/migrate with an account: one row for it under a heading, no card", async () => { | |
| 74 | const github = serveFakeForge(newFakeState("github", "octo", "t0ken-secret")); | |
| 75 | try { | |
| 76 | forgetProviders(dir); | |
| 77 | const connected = await connectOrigin(app, alice, { host: github.url, kind: "github", token: "t0ken-secret" }); | |
| 78 | if (!connected.ok) throw new Error(JSON.stringify(connected.error)); | |
| 79 | const page = await get("/migrate"); | |
| 80 | noCard(page); | |
| 81 | expect(page).toContain('id="migrate-accounts"'); | |
| 82 | expect(page).toContain("Disconnect"); | |
| 83 | expect(page).not.toContain("t0ken-secret"); | |
| 84 | } finally { | |
| 85 | github.stop(); | |
| 86 | } | |
| 87 | }); | |
| 88 | ||
| 89 | test("a job's page: stages are rows under one heading; running streams, failed shows its tone", async () => { | |
| 90 | const running = createJob("", "octo/<b>x", { kind: "migrate", actorId: alice.id }); | |
| 91 | advance(running.id, { stage: "fetching", detail: "receiving 61%" }); | |
| 92 | const live = await get(`/migrate/jobs/${running.id}`); | |
| 93 | noCard(live); | |
| 94 | expect(live).toContain("octo/<b>x"); | |
| 95 | expect(live).toContain("data-stage-name"); | |
| 96 | ||
| 97 | const failed = createJob("", "octo/broken", { kind: "migrate", actorId: alice.id }); | |
| 98 | advance(failed.id, { stage: "failed", error: "boom" }); | |
| 99 | const dead = await get(`/migrate/jobs/${failed.id}`); | |
| 100 | noCard(dead); | |
| 101 | expect(dead).toContain("boom"); | |
| 102 | expect(dead).toContain("Try again"); | |
| 103 | }); |