Public: anyone can read this; the crew can change it.
One test proves the Lost test: from production and from a preview, the pill says where you are and links home, to comment and to tell the crew #7763
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7755-one-test-proves-the-lost-test-from-produ → main 1 commit fork 1 thread · 1 unresolved
1 file
changed +163
−0
test/never-lost.test.ts added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | /** | |
| 2 | * The Lost test (P6 Never lost): from production and from a preview, the | |
| 3 | * pill says where you are and links home, to comment, and to tell the crew. | |
| 4 | * One forge, one app serving one HTML page, asked through the app proxy as | |
| 5 | * production and as the preview of an open pull, signed in and signed out. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { newApp } from "../src/actions/apps.ts"; | |
| 14 | import { signGrant, type AppTarget } from "../src/apps/proxy.ts"; | |
| 15 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 16 | import { TOOLBAR_PATH } from "../src/apps/toolbar.ts"; | |
| 17 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; | |
| 18 | import { createIssue } from "../src/core/issues.ts"; | |
| 19 | import { createToken } from "../src/core/tokens.ts"; | |
| 20 | import { createUser } from "../src/core/users.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | const DOMAIN = "apps.test"; | |
| 27 | const HTML = "<html><body><h1>hi</h1></body></html>"; | |
| 28 | ||
| 29 | describe("never lost: the pill from production and from a preview", () => { | |
| 30 | let dataDir: string; | |
| 31 | let app: App; | |
| 32 | let servers: Servers; | |
| 33 | let runtime: AppsRuntime; | |
| 34 | let backend: ReturnType<typeof Bun.serve>; | |
| 35 | let status: ReturnType<typeof spyOn>; | |
| 36 | let record: AppRecord; | |
| 37 | let pull: number; | |
| 38 | let auth: Record<string, string>; | |
| 39 | let alice: string; | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | backend = Bun.serve({ | |
| 43 | port: 0, | |
| 44 | fetch: () => new Response(HTML, { headers: { "content-type": "text/html; charset=utf-8" } }), | |
| 45 | }); | |
| 46 | dataDir = await mkdtemp(join(tmpdir(), "forge-never-lost-")); | |
| 47 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 48 | servers = await serve(app); | |
| 49 | runtime = new AppsRuntime(app, { | |
| 50 | domain: DOMAIN, | |
| 51 | scheme: "http", | |
| 52 | publicPort: servers.port, | |
| 53 | forgeUrl: servers.url, | |
| 54 | concurrency: 1, | |
| 55 | }); | |
| 56 | await runtime.start(); | |
| 57 | status = spyOn(AppsRuntime.prototype, "envStatus").mockImplementation( | |
| 58 | (_app: unknown, env: string) => | |
| 59 | ({ | |
| 60 | env, | |
| 61 | state: "running", | |
| 62 | port: backend.port, | |
| 63 | commitOid: "a".repeat(40), | |
| 64 | }) as EnvStatus, | |
| 65 | ); | |
| 66 | const u = await createUser(app.db, { | |
| 67 | origin: "seed", | |
| 68 | trusted: true, | |
| 69 | username: "alice", | |
| 70 | email: "[email protected]", | |
| 71 | password: "a-good-passphrase", | |
| 72 | adminIfFirst: true, | |
| 73 | }); | |
| 74 | if (!u.ok) throw new Error(u.error); | |
| 75 | alice = u.value.id; | |
| 76 | const made = await newApp(app, u.value, { name: "notes", description: "x", visibility: "public" }); | |
| 77 | if (!made.ok) throw new Error(String(made.error)); | |
| 78 | record = getAppByRepo(app.db, made.value.repo.id)!; | |
| 79 | const p = createIssue(app.db, { repoId: record.repoId, authorId: alice, title: "a change", kind: "pull" }); | |
| 80 | if (!p.ok) throw new Error(p.error); | |
| 81 | pull = p.value.number; | |
| 82 | auth = { authorization: `Bearer ${createToken(app.db, alice, "t", "rw").token}` }; | |
| 83 | }, 120_000); | |
| 84 | ||
| 85 | afterAll(async () => { | |
| 86 | status.mockRestore(); | |
| 87 | await runtime.stop(); | |
| 88 | servers.stop(); | |
| 89 | backend.stop(true); | |
| 90 | app.close(); | |
| 91 | await rm(dataDir, { recursive: true, force: true }); | |
| 92 | }); | |
| 93 | ||
| 94 | const cookieFor = (env: string) => | |
| 95 | `forge_grant_${record.id.slice(-8)}=${signGrant(dataDir, { app: record, env } as AppTarget, 60_000, Date.now(), { | |
| 96 | id: alice, | |
| 97 | name: "alice", | |
| 98 | role: "admin", | |
| 99 | })}`; | |
| 100 | const ask = (host: string, path: string, cookie?: string) => | |
| 101 | fetch(`${servers.url}${path}`, { | |
| 102 | headers: { host, accept: "text/html", ...(cookie === undefined ? {} : { cookie }) }, | |
| 103 | redirect: "manual", | |
| 104 | }); | |
| 105 | ||
| 106 | const places = [ | |
| 107 | { name: "production", preview: false }, | |
| 108 | { name: "a preview", preview: true }, | |
| 109 | ]; | |
| 110 | ||
| 111 | for (const place of places) { | |
| 112 | describe(place.name, () => { | |
| 113 | const host = () => (place.preview ? `notes--${pull}.${DOMAIN}` : `notes.${DOMAIN}`); | |
| 114 | const env = () => (place.preview ? `pull-${pull}` : "production"); | |
| 115 | ||
| 116 | test("signed in: the page says where you are; the pill's links all answer", async () => { | |
| 117 | const res = await ask(host(), "/", cookieFor(env())); | |
| 118 | expect(res.status).toBe(200); | |
| 119 | const html = await res.text(); | |
| 120 | const tag = /<script src="([^"]+)"([^>]*) defer><\/script>/.exec(html); | |
| 121 | expect(tag).not.toBeNull(); | |
| 122 | expect(tag![1]).toBe(`${servers.url}${TOOLBAR_PATH}`); | |
| 123 | expect(tag![2]).toContain(' data-app="notes"'); | |
| 124 | expect(tag![2]).toContain(' data-repo="alice/notes"'); | |
| 125 | expect(tag![2]).toContain(` data-env="${env()}"`); | |
| 126 | if (place.preview) expect(tag![2]).toContain(` data-pull="${pull}"`); | |
| 127 | else expect(tag![2]).not.toContain("data-pull"); | |
| 128 | ||
| 129 | // The crew door answers the owner with the forge's own tell page. | |
| 130 | const crew = await ask(host(), "/__forge/crew", cookieFor(env())); | |
| 131 | expect(crew.status).toBe(200); | |
| 132 | const tell = ((await crew.json()) as { tell?: string }).tell; | |
| 133 | expect(tell).toBe(`${servers.url}/alice/notes/tell`); | |
| 134 | ||
| 135 | // Home, comment/tell/make-it-yours, and the tell form: forge origin, 200. | |
| 136 | const query = new URLSearchParams({ env: env(), page: `http://${host()}/` }); | |
| 137 | const links = [ | |
| 138 | `${servers.url}/`, | |
| 139 | `${servers.url}/alice/notes/toolbar?${query}`, | |
| 140 | `${tell}?${new URLSearchParams({ page: `http://${host()}/`, ...(place.preview ? { pull: String(pull) } : {}) })}`, | |
| 141 | ]; | |
| 142 | for (const link of links) { | |
| 143 | expect(new URL(link).origin).toBe(servers.url); | |
| 144 | const got = await fetch(link, { headers: auth, redirect: "manual" }); | |
| 145 | expect(got.status).toBe(200); | |
| 146 | } | |
| 147 | const form = await (await fetch(links[2]!, { headers: auth })).text(); | |
| 148 | expect(form).toContain("<form"); | |
| 149 | }); | |
| 150 | ||
| 151 | test("signed out: still says where you are, offers sign-in in place of tell", async () => { | |
| 152 | const html = await (await ask(host(), "/")).text(); | |
| 153 | expect(html).toContain(' data-app="notes"'); | |
| 154 | expect(html).toContain(` data-env="${env()}"`); | |
| 155 | if (place.preview) expect(html).toContain(` data-pull="${pull}"`); | |
| 156 | expect(await (await ask(host(), "/__forge/crew")).json()).toEqual({}); | |
| 157 | const door = await (await ask(host(), "/__forge/")).text(); | |
| 158 | expect(door).toContain("/__forge/signin"); | |
| 159 | expect(door).not.toContain("/alice/notes/tell"); | |
| 160 | }); | |
| 161 | }); | |
| 162 | } | |
| 163 | }); |
new above
offline
These links are assembled by the test, not read from the pill. If the script changes its hrefs, this still passes. Parse or execute the script output instead.