Public: anyone can read this; the crew can change it.
When the app is not up, the studio says why and gives one click back #47
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/41-when-the-app-is-not-up-the-studio-says-w → main 2 commits fork
6 files
changed +305
−79
docs/DESIGN.md +1 −1
| @@ -187,7 +187,7 @@ specification. | ||
| 187 | 187 | | **run state badge** `runStateBadge()` | `.badge` | `queued` · `running` (attn) · `ok` (success: _Passed_, or _Done_ for an agent run) · `failed` (danger) · `cancelled` (outline) | the one badge a check wears on a pull, a run wears in a list, and a log page wears in its head; the log page's script repaints it on `done` | `[✓ passed]` / `[✗ failed]` / `[· running]` | |
| 188 | 188 | | **env state badge** `envStateBadge()` | `.badge` | `stopped` (outline) · `deploying` (attn) · `running` (success) · `failed` (danger) | what production, or a preview, is doing now; `envTone()` is the same answer as a `dot()` for a list row | the same word in the same tone after the environment's name | |
| 189 | 189 | | **app strip** | `.app-strip` | — | one line under the repo header of a repository that runs: env state badge, `sha`, the production URL as a link **through `…/app/production/open`** (never the bare hostname — a private app would 404), the preview count, App | one line under the where strip: `● running abc1234 · hello.localhost` | |
| 190 | | **environment card** | `.card.env-card` (`.env-facts`, `.env-error`) | `data-tone=danger` when failed | name and state in the head; commit, uptime and the run in the body; a failed deploy's reason on its own line; Open / Redeploy / Stop in the foot — a card and not a row so an error has a whole line to be read on | a box per environment with the same three lines | | |
| 190 | | **environment card** | `.card.env-card` (`.env-facts`, `.env-error`) | `data-tone=danger` when failed | name and state in the head; `envFacts()` in the body — commit, how fast it came up, uptime, a link to the deploy run, and the deployment's own note and error, each on its own line; Open plus `envActions()` (Redeploy / Stop, writers only) in the foot. The facts and the actions are functions, not markup: a pull's preview card and the studio head render the same ones | a box per environment with the same three lines | | |
| 191 | 191 | | **checks line** | `.checks-line` (`.ok`, `.bad`, `.now`) | — | inside the merge box, in the reviews chip's voice: `✓ Checks passed · deploy abc1234 · a passing deploy is required`. Rendered only where the repository runs as an app or requires checks; a merge box gains _Waiting on checks_ | the same line under the review line | |
| 192 | 192 | | **run log** | `pre.run-log` | `data-stream` while the run is going | the log as written, wrapped so a phone never scrolls sideways; a script tails the SSE stream and follows the bottom until the reader scrolls up, a `<meta refresh>` does it without one | a scrolling pane that follows the tail | |
| 193 | 193 | | **assignee card** | `.card` with an `.edit-row` form | — | who is on it, then one select and one button for a writer. People first, the crew last and marked `(agent)`, because assigning an agent is what starts it working and the choice is not the same kind of choice | a picker with people, then agents | |
src/web/pages/apps.ts +4 −43
| @@ -59,13 +59,14 @@ import { | ||
| 59 | 59 | card, |
| 60 | 60 | dot, |
| 61 | 61 | empty, |
| 62 | envActions, | |
| 63 | envFacts, | |
| 62 | 64 | envStateBadge, |
| 63 | 65 | envTone, |
| 64 | 66 | icon, |
| 65 | 67 | listCard, |
| 66 | 68 | row, |
| 67 | 69 | selectField, |
| 68 | servedPhrase, | |
| 69 | 70 | sha, |
| 70 | 71 | time, |
| 71 | 72 | userLink, |
| @@ -473,36 +474,11 @@ export function appStrip(ctx: Ctx, repo: Repo): Html { | ||
| 473 | 474 | |
| 474 | 475 | // ---- the app page ---- |
| 475 | 476 | |
| 476 | const envFacts = (base: string, env: EnvStatus): Html => | |
| 477 | html`<div class="env-facts"> | |
| 478 | ${when(env.commitOid !== "", () => | |
| 479 | sha(env.commitOid, `${base}/commit/${env.commitOid}`), | |
| 480 | )} | |
| 481 | ${when(servedPhrase(env), (phrase) => html`<span>live ${phrase}</span>`)} | |
| 482 | ${when(env.since, (since) => html`<span>up ${time(since)}</span>`)} | |
| 483 | ${when( | |
| 484 | env.runId, | |
| 485 | (id) => | |
| 486 | html`<a href="${base}/runs/${id}" | |
| 487 | >${env.state === "deploying" | |
| 488 | ? "deploy in progress" | |
| 489 | : env.note !== undefined | |
| 490 | ? "verifying tests" | |
| 491 | : "last deploy"}</a | |
| 492 | >`, | |
| 493 | )} | |
| 494 | </div>`; | |
| 495 | ||
| 496 | 477 | /** One environment: what it is doing, the facts, and the buttons. */ |
| 497 | 478 | function envCard(base: string, env: EnvStatus, writer: boolean): Html { |
| 498 | 479 | const number = envPull(env.env); |
| 499 | 480 | return card( |
| 500 | html`${envFacts(base, env)} | |
| 501 | <p class="env-facts mt"> | |
| 502 | <span class="mono truncate" title="${env.url}">${env.url}</span> | |
| 503 | </p> | |
| 504 | ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)} | |
| 505 | ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}`, | |
| 481 | envFacts(base, env, { url: true }), | |
| 506 | 482 | { |
| 507 | 483 | extra: "env-card", |
| 508 | 484 | ...(env.state === "failed" || env.error !== undefined |
| @@ -521,20 +497,7 @@ function envCard(base: string, env: EnvStatus, writer: boolean): Html { | ||
| 521 | 497 | variant: "outline", |
| 522 | 498 | icon: "external", |
| 523 | 499 | })} |
| 524 | ${when( | |
| 525 | writer, | |
| 526 | () => | |
| 527 | html`<form method="post" action="${base}/app/${env.env}/redeploy"> | |
| 528 | ${btn("Redeploy", { size: "sm", icon: "sync" })} | |
| 529 | </form> | |
| 530 | ${when( | |
| 531 | env.state !== "stopped", | |
| 532 | () => | |
| 533 | html`<form method="post" action="${base}/app/${env.env}/stop"> | |
| 534 | ${btn("Stop", { size: "sm", icon: "square" })} | |
| 535 | </form>`, | |
| 536 | )}`, | |
| 537 | )}`, | |
| 500 | ${envActions(base, env, writer)}`, | |
| 538 | 501 | }, |
| 539 | 502 | ); |
| 540 | 503 | } |
| @@ -718,8 +681,6 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 718 | 681 | } |
| 719 | 682 | return card( |
| 720 | 683 | html`${envFacts(base, env)} |
| 721 | ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)} | |
| 722 | ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)} | |
| 723 | 684 | <div class="bar mt"> |
| 724 | 685 | ${btn("Open preview", { |
| 725 | 686 | href: `${base}/app/${env.env}/open`, |
src/web/pages/studio.ts +20 −32
| @@ -78,15 +78,18 @@ import { liveClientJs, liveMount } from "../live.ts"; | ||
| 78 | 78 | import { isMode, modeCookie } from "../mode.ts"; |
| 79 | 79 | import { |
| 80 | 80 | avatar, |
| 81 | badge, | |
| 81 | 82 | btn, |
| 82 | 83 | card, |
| 83 | 84 | dot, |
| 84 | 85 | empty, |
| 86 | envActions, | |
| 87 | envFacts, | |
| 88 | envPhrase, | |
| 85 | 89 | icon, |
| 86 | 90 | listCard, |
| 87 | 91 | pill, |
| 88 | 92 | row, |
| 89 | servedPhrase, | |
| 90 | 93 | time, |
| 91 | 94 | userTag, |
| 92 | 95 | type Tone, |
| @@ -628,35 +631,20 @@ export async function studioApp( | ||
| 628 | 631 | const envs: EnvStatus[] = runtime === undefined ? [] : runtime.status(record); |
| 629 | 632 | const production = envs.find((e) => e.env === "production"); |
| 630 | 633 | const url = runtime?.urlFor(record, "production"); |
| 631 | const state = production?.state ?? "stopped"; | |
| 632 | const servedAfter = production ? servedPhrase(production) : undefined; | |
| 633 | const liveWord = | |
| 634 | state === "running" | |
| 635 | ? production?.error !== undefined | |
| 636 | ? "serving · tests failed" | |
| 637 | : servedAfter !== undefined | |
| 638 | ? `live ${servedAfter}` | |
| 639 | : "live" | |
| 640 | : state === "failed" | |
| 641 | ? "not up" | |
| 642 | : state === "stopped" | |
| 643 | ? "not running" | |
| 644 | : "starting"; | |
| 645 | const liveTone: Tone = | |
| 646 | state === "running" ? "success" : state === "failed" ? "danger" : "default"; | |
| 634 | const writer = canWrite(db, repo, ctx.auth.user); | |
| 635 | // The state in one word, and under it the same facts the App page shows — | |
| 636 | // why it is not up, and the deploy whose log says the rest. | |
| 637 | const live = envPhrase(production); | |
| 647 | 638 | const head = html`<div class="page-head studio-head"> |
| 648 | 639 | <div class="stack" style="gap: 4px"> |
| 649 | 640 | <h1> |
| 650 | 641 | ${repo.name} |
| 651 | <span | |
| 652 | class="badge" | |
| 653 | data-variant="${liveTone === "success" | |
| 654 | ? "success" | |
| 655 | : liveTone === "danger" | |
| 656 | ? "danger" | |
| 657 | : "secondary"}" | |
| 658 | >${liveWord}</span | |
| 659 | > | |
| 642 | ${badge( | |
| 643 | live.word, | |
| 644 | live.tone === "default" | |
| 645 | ? { variant: "secondary" } | |
| 646 | : { tone: live.tone }, | |
| 647 | )} | |
| 660 | 648 | </h1> |
| 661 | 649 | <p class="muted"> |
| 662 | 650 | ${repo.description || "No description yet."} |
| @@ -666,13 +654,8 @@ export async function studioApp( | ||
| 666 | 654 | <a href="${base}/app/production/open" class="mono" |
| 667 | 655 | >${url.replace(/^https?:\/\//, "")}</a |
| 668 | 656 | >`} |
| 669 | ${production?.commitOid | |
| 670 | ? html`· | |
| 671 | ${production.commitOid.slice(0, 7)}${production.since | |
| 672 | ? html` since ${time(production.since)}` | |
| 673 | : null}` | |
| 674 | : null} | |
| 675 | 657 | </p> |
| 658 | ${when(production, (env) => envFacts(base, env))} | |
| 676 | 659 | </div> |
| 677 | 660 | <span class="spacer"></span> |
| 678 | 661 | ${lookNow(ctx, repo, record)} |
| @@ -681,6 +664,7 @@ export async function studioApp( | ||
| 681 | 664 | variant: "primary", |
| 682 | 665 | icon: "external", |
| 683 | 666 | })} |
| 667 | ${when(production, (env) => envActions(base, env, writer))} | |
| 684 | 668 | </div>`; |
| 685 | 669 | |
| 686 | 670 | // ---- the box ---- |
| @@ -1409,6 +1393,10 @@ export const studioCss = /* css */ ` | ||
| 1409 | 1393 | .studio-tell textarea { min-height: 72px; resize: vertical; } |
| 1410 | 1394 | .studio-tell .bar { display: flex; align-items: center; gap: 8px; margin-top: 8px; } |
| 1411 | 1395 | .studio-head h1 { display: flex; align-items: center; gap: 10px; } |
| 1396 | /* The facts under the title take the whole line; the two buttons that act | |
| 1397 | on production sit with the rest, each in its own POST form. */ | |
| 1398 | .studio-head .env-facts { margin-top: 2px; } | |
| 1399 | .studio-head form { display: inline-flex; } | |
| 1412 | 1400 | .badge[data-variant="accent"] { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); background: color-mix(in srgb, var(--accent) 12%, transparent); } |
| 1413 | 1401 | .badge[data-word="working"] svg { animation: spin 1.1s linear infinite; } |
| 1414 | 1402 | .studio-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } |
src/web/style/content.ts +5 −3
| @@ -553,10 +553,12 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 553 | 553 | .app-strip .spacer { flex: 1; } |
| 554 | 554 | /* One environment: name and state in the head, the facts and the reason it |
| 555 | 555 | failed in the body, its buttons in the foot. A card rather than a row so a |
| 556 | deploy error has a whole line to be read on. */ | |
| 556 | deploy error has a whole line to be read on. The facts and the error read | |
| 557 | the same wherever they are shown — the App page's card, a pull's preview, | |
| 558 | the studio head — so they are not scoped to the card. */ | |
| 557 | 559 | .env-card .card-head { gap: 10px; } |
| 558 | .env-card .env-facts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted-fg); } | |
| 559 | .env-card .env-error { color: var(--danger); font-size: 13px; margin-top: 6px; overflow-wrap: anywhere; text-wrap: pretty; } | |
| 560 | .env-facts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted-fg); } | |
| 561 | .env-error { color: var(--danger); font-size: 13px; margin-top: 6px; overflow-wrap: anywhere; text-wrap: pretty; } | |
| 560 | 562 | .env-card .card-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| 561 | 563 | .env-card .card-foot form { display: inline-flex; } |
| 562 | 564 | /* The look: a phone beside a desktop, each in a hairline frame. The phone |
src/web/ui.ts +97
| @@ -280,6 +280,103 @@ export function envTone(state: EnvState | undefined): Tone { | ||
| 280 | 280 | : "default"; |
| 281 | 281 | } |
| 282 | 282 | |
| 283 | /** | |
| 284 | * An environment's state in a person's words rather than a badge's — | |
| 285 | * `live`, `serving · tests failed`, `not up`, `not running` — for a head | |
| 286 | * that speaks one line. A card head wears `envStateBadge` instead. How fast | |
| 287 | * it came up belongs to `envFacts`, which sits under this and says it once. | |
| 288 | * | |
| 289 | * A serving app is never called down because its tests are red: the words | |
| 290 | * say the tests failed, the tone stays the tone of something that answers. | |
| 291 | */ | |
| 292 | export function envPhrase(env: EnvStatus | undefined): { | |
| 293 | word: string; | |
| 294 | tone: Tone; | |
| 295 | } { | |
| 296 | if (env === undefined || env.state === "stopped") | |
| 297 | return { word: "not running", tone: "default" }; | |
| 298 | if (env.state === "failed") return { word: "not up", tone: "danger" }; | |
| 299 | if (env.state === "deploying") return { word: "starting", tone: "default" }; | |
| 300 | return { | |
| 301 | word: env.error === undefined ? "live" : "serving · tests failed", | |
| 302 | tone: "success", | |
| 303 | }; | |
| 304 | } | |
| 305 | ||
| 306 | export interface EnvFactsOptions { | |
| 307 | /** Name the environment's address in full — a card has room for it, a | |
| 308 | * head that already links the app does not. */ | |
| 309 | readonly url?: boolean; | |
| 310 | } | |
| 311 | ||
| 312 | /** | |
| 313 | * What an environment is doing, as facts: the commit it runs, how fast it | |
| 314 | * came up, how long it has been up, a link to the deploy that produced it, | |
| 315 | * and — when the deployment has words of its own — its note and its error. | |
| 316 | * Every page showing an environment renders this one, so `not up` never | |
| 317 | * says less on one page than on another. Facts, not a card: the caller | |
| 318 | * decides what box they sit in. | |
| 319 | */ | |
| 320 | export function envFacts( | |
| 321 | base: string, | |
| 322 | env: EnvStatus, | |
| 323 | o: EnvFactsOptions = {}, | |
| 324 | ): Html { | |
| 325 | return html`<div class="env-facts"> | |
| 326 | ${when(env.commitOid !== "", () => | |
| 327 | sha(env.commitOid, `${base}/commit/${env.commitOid}`), | |
| 328 | )} | |
| 329 | ${when(servedPhrase(env), (phrase) => html`<span>live ${phrase}</span>`)} | |
| 330 | ${when(env.since, (since) => html`<span>up ${time(since)}</span>`)} | |
| 331 | ${when( | |
| 332 | env.runId, | |
| 333 | (id) => | |
| 334 | html`<a href="${base}/runs/${id}" | |
| 335 | >${env.state === "deploying" | |
| 336 | ? "deploy in progress" | |
| 337 | : env.note !== undefined | |
| 338 | ? "verifying tests" | |
| 339 | : "last deploy"}</a | |
| 340 | >`, | |
| 341 | )} | |
| 342 | </div> | |
| 343 | ${when( | |
| 344 | o.url === true, | |
| 345 | () => | |
| 346 | html`<p class="env-facts mt"> | |
| 347 | <span class="mono truncate" title="${env.url}">${env.url}</span> | |
| 348 | </p>`, | |
| 349 | )} | |
| 350 | ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)} | |
| 351 | ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}`; | |
| 352 | } | |
| 353 | ||
| 354 | /** | |
| 355 | * The two things a writer can do to an environment. Both are POST forms — | |
| 356 | * the routes they post to make the same-origin check — and a reader is | |
| 357 | * handed nothing at all. | |
| 358 | */ | |
| 359 | export function envActions( | |
| 360 | base: string, | |
| 361 | env: EnvStatus, | |
| 362 | writer: boolean, | |
| 363 | ): Html { | |
| 364 | return when( | |
| 365 | writer, | |
| 366 | () => | |
| 367 | html`<form method="post" action="${base}/app/${env.env}/redeploy"> | |
| 368 | ${btn("Redeploy", { size: "sm", icon: "sync" })} | |
| 369 | </form> | |
| 370 | ${when( | |
| 371 | env.state !== "stopped", | |
| 372 | () => | |
| 373 | html`<form method="post" action="${base}/app/${env.env}/stop"> | |
| 374 | ${btn("Stop", { size: "sm", icon: "square" })} | |
| 375 | </form>`, | |
| 376 | )}`, | |
| 377 | ); | |
| 378 | } | |
| 379 | ||
| 283 | 380 | export interface LabelLike { |
| 284 | 381 | readonly name: string; |
| 285 | 382 | readonly color: string; |
test/studio.test.ts +178
| @@ -8,6 +8,9 @@ import { tmpdir } from "node:os"; | ||
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { createApp, type App } from "../src/app.ts"; |
| 10 | 10 | import { enableApp } from "../src/actions/apps.ts"; |
| 11 | import type { EnvName } from "../src/apps/data.ts"; | |
| 12 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 13 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; | |
| 11 | 14 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 12 | 15 | import { openPull } from "../src/actions/pulls.ts"; |
| 13 | 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| @@ -512,3 +515,178 @@ describe("the studio home does not wait on the decider", () => { | ||
| 512 | 515 | expect(again).toContain("by the decider"); |
| 513 | 516 | }); |
| 514 | 517 | }); |
| 518 | ||
| 519 | /** | |
| 520 | * The studio head of an app that is not up: the same facts the App page | |
| 521 | * shows — why, the deploy that says the rest, and the way to try again. | |
| 522 | */ | |
| 523 | describe("the studio head says why the app is not up", () => { | |
| 524 | let dataDir: string; | |
| 525 | let app: App; | |
| 526 | let servers: Servers; | |
| 527 | let base: string; | |
| 528 | let repo: Repo; | |
| 529 | let record: AppRecord; | |
| 530 | let runtime: AppsRuntime; | |
| 531 | let deploy: string; | |
| 532 | let writer: string; | |
| 533 | let reader: string; | |
| 534 | ||
| 535 | const head = (cookie: string) => | |
| 536 | fetch(`${base}/alice/notes`, { | |
| 537 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 538 | redirect: "manual", | |
| 539 | }).then((r) => r.text()); | |
| 540 | ||
| 541 | const signIn = async (username: string): Promise<string> => | |
| 542 | ( | |
| 543 | ( | |
| 544 | await fetch(`${base}/signin`, { | |
| 545 | method: "POST", | |
| 546 | body: new URLSearchParams({ | |
| 547 | username, | |
| 548 | password: "a-good-passphrase", | |
| 549 | }), | |
| 550 | redirect: "manual", | |
| 551 | }) | |
| 552 | ).headers.get("set-cookie") ?? "" | |
| 553 | ).split(";")[0]!; | |
| 554 | ||
| 555 | /** | |
| 556 | * An environment as the runtime holds it, without running a deploy: the | |
| 557 | * page reads `runtime.status(record)`, and a real failure would mean a | |
| 558 | * real install and a real health timeout for a rendering test. | |
| 559 | */ | |
| 560 | interface Slot { | |
| 561 | readonly app: AppRecord; | |
| 562 | readonly env: EnvName; | |
| 563 | readonly chain: Promise<void>; | |
| 564 | readonly restarts: number[]; | |
| 565 | readonly live?: unknown; | |
| 566 | readonly lastError?: string; | |
| 567 | readonly lastRunId?: string; | |
| 568 | } | |
| 569 | const put = ( | |
| 570 | slot: Omit<Slot, "app" | "env" | "chain" | "restarts">, | |
| 571 | ): void => { | |
| 572 | (runtime as unknown as { envs: Map<string, Slot> }).envs.set( | |
| 573 | `${record.id}/production`, | |
| 574 | { | |
| 575 | app: record, | |
| 576 | env: "production", | |
| 577 | chain: Promise.resolve(), | |
| 578 | restarts: [], | |
| 579 | ...slot, | |
| 580 | }, | |
| 581 | ); | |
| 582 | }; | |
| 583 | ||
| 584 | beforeAll(async () => { | |
| 585 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-notup-")); | |
| 586 | app = createApp({ dataDir, port: 0 }); | |
| 587 | servers = await serve(app); | |
| 588 | base = servers.url; | |
| 589 | runtime = new AppsRuntime(app, { | |
| 590 | domain: "apps.test", | |
| 591 | scheme: "http", | |
| 592 | publicPort: servers.port, | |
| 593 | forgeUrl: servers.url, | |
| 594 | concurrency: 1, | |
| 595 | }); | |
| 596 | const a = await createUser(app.db, { | |
| 597 | username: "alice", | |
| 598 | email: "[email protected]", | |
| 599 | password: "a-good-passphrase", | |
| 600 | adminIfFirst: true, | |
| 601 | }); | |
| 602 | if (!a.ok) throw new Error(a.error); | |
| 603 | const b = await createUser(app.db, { | |
| 604 | username: "bob", | |
| 605 | email: "[email protected]", | |
| 606 | password: "a-good-passphrase", | |
| 607 | }); | |
| 608 | if (!b.ok) throw new Error(b.error); | |
| 609 | writer = await signIn("alice"); | |
| 610 | reader = await signIn("bob"); | |
| 611 | const made = await createRepo(app, a.value, { | |
| 612 | name: "notes", | |
| 613 | visibility: "public", | |
| 614 | description: "a place for notes", | |
| 615 | }); | |
| 616 | if (!made.ok) throw new Error(String(made.error)); | |
| 617 | repo = made.value; | |
| 618 | const seeded = await commitToBranch(app, a.value, repo, { | |
| 619 | message: "Start", | |
| 620 | files: [{ path: "README.md", content: "notes\n" }], | |
| 621 | }); | |
| 622 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 623 | const enabled = await enableApp(app, a.value, repo, { slug: "notes" }); | |
| 624 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 625 | record = getAppByRepo(app.db, repo.id)!; | |
| 626 | const run = createRun(app.db, { | |
| 627 | repoId: repo.id, | |
| 628 | kind: "deploy", | |
| 629 | env: "production", | |
| 630 | commitOid: seeded.value.commitOid, | |
| 631 | reason: "push", | |
| 632 | }); | |
| 633 | claimRun(app.db, run.id); | |
| 634 | finishRun(app.db, run.id, { | |
| 635 | state: "failed", | |
| 636 | summary: "the deploy failed", | |
| 637 | }); | |
| 638 | deploy = run.id; | |
| 639 | }); | |
| 640 | ||
| 641 | afterAll(async () => { | |
| 642 | servers.stop(); | |
| 643 | app.close(); | |
| 644 | await rm(dataDir, { recursive: true, force: true }); | |
| 645 | }); | |
| 646 | ||
| 647 | test("a failed production says what failed, links its deploy, and offers Redeploy to a writer only", async () => { | |
| 648 | put({ | |
| 649 | lastError: "the deploy failed: <install> exited 1", | |
| 650 | lastRunId: deploy, | |
| 651 | }); | |
| 652 | const mine = await head(writer); | |
| 653 | expect(mine).toContain("not up"); | |
| 654 | // The error is the app's own output: shown, and escaped. | |
| 655 | expect(mine).toContain("the deploy failed: <install> exited 1"); | |
| 656 | expect(mine).not.toContain("<install>"); | |
| 657 | expect(mine).toContain(`href="/alice/notes/runs/${deploy}"`); | |
| 658 | expect(mine).toContain('action="/alice/notes/app/production/redeploy"'); | |
| 659 | expect(mine).toContain('action="/alice/notes/app/production/stop"'); | |
| 660 | ||
| 661 | const theirs = await head(reader); | |
| 662 | expect(theirs).toContain("not up"); | |
| 663 | expect(theirs).toContain("the deploy failed: <install> exited 1"); | |
| 664 | expect(theirs).toContain(`href="/alice/notes/runs/${deploy}"`); | |
| 665 | expect(theirs).not.toContain("/app/production/redeploy"); | |
| 666 | expect(theirs).not.toContain("/app/production/stop"); | |
| 667 | }); | |
| 668 | ||
| 669 | test("a stopped production says it is not running and offers Redeploy, not Stop", async () => { | |
| 670 | put({ lastRunId: deploy }); | |
| 671 | const mine = await head(writer); | |
| 672 | expect(mine).toContain("not running"); | |
| 673 | expect(mine).toContain('action="/alice/notes/app/production/redeploy"'); | |
| 674 | expect(mine).not.toContain('action="/alice/notes/app/production/stop"'); | |
| 675 | }); | |
| 676 | ||
| 677 | test("a serving app with red tests still reads as serving, with the failure in its own words", async () => { | |
| 678 | put({ | |
| 679 | live: { | |
| 680 | commitOid: "0".repeat(40), | |
| 681 | handle: { port: 4321 }, | |
| 682 | runId: deploy, | |
| 683 | since: new Date(Date.now() - 60_000).toISOString(), | |
| 684 | testsFailed: "1 of 3 tests failed", | |
| 685 | }, | |
| 686 | }); | |
| 687 | const mine = await head(writer); | |
| 688 | expect(mine).toContain("serving · tests failed"); | |
| 689 | expect(mine).not.toContain("not up"); | |
| 690 | expect(mine).toContain("serving · tests failed: 1 of 3 tests failed"); | |
| 691 | }); | |
| 692 | }); |
new above
offline