Public: anyone can read this; the crew can change it.
`ssh <app>@<forge> pulls` prints the app's open pulls a viewer may see, newest first, with each head's check word #6116
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6109-ssh-app-forge-pulls-prints-the-app-s-ope → main 1 commit fork
6 files
changed +367
−10
README.md +2
| @@ -307,6 +307,8 @@ ssh -p 2222 localhost # the terminal UI | ||
| 307 | 307 | git clone ssh://localhost:2222/you/hello.git # git, same port |
| 308 | 308 | |
| 309 | 309 | |
| 310 | An app's open pulls, newest five first, one line each (`#n title · green|red|running|none`), need no tty: run `pulls` on the app's login — `ssh <app>@<forge> pulls`, with the app's page printing the exact command as `FORGE_APP_SSH` (the terminal is off when that is unset). A private app answers only a key its gate admits; sealed pulls show to their author, writers and the crew (`src/apps/door_pulls.ts`). | |
| 311 | ||
| 310 | 312 | A big repository clones over either transport. To skip its large files until a |
| 311 | 313 | checkout needs them, run `git clone --filter=blob:none <url>`. See |
| 312 | 314 | [](docs/GIT.md). |
src/apps/door_pulls.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | /** | |
| 2 | * An app's open pulls, for a door that cannot draw a page (#6109): number, | |
| 3 | * title, head and the state of the newest check on it, newest first, in one | |
| 4 | * indexed read. Who may see which is the pages' own rule: a repository you | |
| 5 | * cannot read has none, a sealed pull is its author's, a writer's and the | |
| 6 | * crew's, and a suspended account is a guest (`core/sealed.ts`). | |
| 7 | */ | |
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { canRead, type Repo } from "../core/repos.ts"; | |
| 10 | import type { RunState } from "../core/runs.ts"; | |
| 11 | import { canSeeSealed } from "../core/sealed.ts"; | |
| 12 | import { isActive, type User } from "../core/users.ts"; | |
| 13 | ||
| 14 | export interface DoorPull { | |
| 15 | readonly number: number; | |
| 16 | readonly title: string; | |
| 17 | readonly headOid: string; | |
| 18 | /** The newest check or deploy run of this pull, whatever head it ran. */ | |
| 19 | readonly run: { state: RunState; commitOid: string } | undefined; | |
| 20 | } | |
| 21 | ||
| 22 | /** | |
| 23 | * ?1 repo, ?2 whether sealed pulls are all visible, ?3 the viewer's id | |
| 24 | * (empty for a guest), ?4 the limit. Walks `issue_repo_kind_number` newest | |
| 25 | * first and stops at the limit; each run is one probe of `run_issue`. | |
| 26 | */ | |
| 27 | export const OPEN_PULLS_SQL = ` | |
| 28 | SELECT i.number AS number, i.title AS title, p.head_oid AS head_oid, | |
| 29 | r.state AS run_state, r.commit_oid AS run_commit | |
| 30 | FROM issue i | |
| 31 | JOIN pull p ON p.issue_id = i.id | |
| 32 | LEFT JOIN run r ON r.id = ( | |
| 33 | SELECT id FROM run | |
| 34 | WHERE issue_id = i.id AND kind IN ('deploy','check') | |
| 35 | ORDER BY id DESC LIMIT 1) | |
| 36 | WHERE i.repo_id = ?1 AND i.kind = 'pull' AND i.state = 'open' | |
| 37 | AND (i.sealed = 0 OR ?2 = 1 OR i.author_id = ?3) | |
| 38 | ORDER BY i.number DESC | |
| 39 | LIMIT ?4`; | |
| 40 | ||
| 41 | interface Row { | |
| 42 | number: number; | |
| 43 | title: string; | |
| 44 | head_oid: string; | |
| 45 | run_state: RunState | null; | |
| 46 | run_commit: string | null; | |
| 47 | } | |
| 48 | ||
| 49 | export function openPulls( | |
| 50 | db: Database, | |
| 51 | repo: Repo, | |
| 52 | viewer: User | undefined, | |
| 53 | limit = 5, | |
| 54 | ): DoorPull[] { | |
| 55 | const who = viewer !== undefined && isActive(viewer) ? viewer : undefined; | |
| 56 | if (!canRead(db, repo, who)) return []; | |
| 57 | const n = Math.min(Math.max(Math.trunc(limit) || 0, 0), 50); | |
| 58 | const rows = db | |
| 59 | .query<Row, [string, number, string, number]>(OPEN_PULLS_SQL) | |
| 60 | .all(repo.id, canSeeSealed(db, repo, who) ? 1 : 0, who?.id ?? "", n); | |
| 61 | return rows.map((r) => ({ | |
| 62 | number: r.number, | |
| 63 | title: r.title, | |
| 64 | headOid: r.head_oid, | |
| 65 | run: | |
| 66 | r.run_state === null | |
| 67 | ? undefined | |
| 68 | : { state: r.run_state, commitOid: r.run_commit ?? "" }, | |
| 69 | })); | |
| 70 | } |
src/apps/tty.ts +23 −10
| @@ -105,6 +105,26 @@ export function ttyRefusalText( | ||
| 105 | 105 | } |
| 106 | 106 | } |
| 107 | 107 | |
| 108 | /** | |
| 109 | * Who may open an app's terminal or ask it a question over ssh: the one | |
| 110 | * gate, so every door refuses in the same words. An app you may not see is | |
| 111 | * not there. | |
| 112 | */ | |
| 113 | export function appGate( | |
| 114 | forge: Forge, | |
| 115 | user: User | undefined, | |
| 116 | target: { app: AppRecord }, | |
| 117 | ): TtyRefusal | undefined { | |
| 118 | const repo = getRepoById(forge.db, target.app.repoId); | |
| 119 | if (!repo || !canRead(forge.db, repo, user)) return "not_found"; | |
| 120 | if (target.app.access === "private" && user === undefined) return "private"; | |
| 121 | // A private app's terminal is its writers' and crew's: a person the app | |
| 122 | // names may enter the app, not its terminal (#752). | |
| 123 | if (!canEnterApp(forge.db, repo, target.app, user, { named: false })) | |
| 124 | return "not_found"; | |
| 125 | return undefined; | |
| 126 | } | |
| 127 | ||
| 108 | 128 | /** |
| 109 | 129 | * Run the app's terminal on `host` until it exits. Every reason it cannot |
| 110 | 130 | * is a refusal the caller says in words; a program that ran resolves with |
| @@ -116,16 +136,9 @@ export async function runAppTty( | ||
| 116 | 136 | user: User | undefined, |
| 117 | 137 | target: { app: AppRecord; env: EnvName }, |
| 118 | 138 | ): Promise<Result<number, { refusal: TtyRefusal; detail: string }>> { |
| 119 | const repo = getRepoById(forge.db, target.app.repoId); | |
| 120 | // The same conflation as everywhere: an app you may not see is not there. | |
| 121 | if (!repo || !canRead(forge.db, repo, user)) | |
| 122 | return err({ refusal: "not_found", detail: "" }); | |
| 123 | if (target.app.access === "private" && user === undefined) | |
| 124 | return err({ refusal: "private", detail: "" }); | |
| 125 | // A private app's terminal is its writers' and crew's: a person the app | |
| 126 | // names may enter the app, not its terminal (#752). | |
| 127 | if (!canEnterApp(forge.db, repo, target.app, user, { named: false })) | |
| 128 | return err({ refusal: "not_found", detail: "" }); | |
| 139 | const refused = appGate(forge, user, target); | |
| 140 | if (refused !== undefined) return err({ refusal: refused, detail: "" }); | |
| 141 | const repo = getRepoById(forge.db, target.app.repoId)!; | |
| 129 | 142 | if (host.term === undefined) return err({ refusal: "no_pty", detail: "" }); |
| 130 | 143 | const runtime = appsRuntime(forge); |
| 131 | 144 | // A terminal opened on a sleeping environment wakes it first. |
src/apps/tty_pulls.ts added +46
| @@ -0,0 +1,46 @@ | ||
| 1 | /** | |
| 2 | * `ssh <app>@<forge> pulls`: the app's open pulls, one plain line each — | |
| 3 | * `#n title · <check word>` — for a viewer the key makes. No pty, and the | |
| 4 | * same gate as the app's terminal (`appGate`), so a refusal is the same | |
| 5 | * sentence. | |
| 6 | */ | |
| 7 | import type { App as Forge } from "../app.ts"; | |
| 8 | import type { AppRecord } from "../core/apps.ts"; | |
| 9 | import { getRepoById } from "../core/repos.ts"; | |
| 10 | import type { User } from "../core/users.ts"; | |
| 11 | import type { SshSession } from "../ssh/server.ts"; | |
| 12 | import { checkWord } from "./check_word.ts"; | |
| 13 | import type { EnvName } from "./data.ts"; | |
| 14 | import { openPulls } from "./door_pulls.ts"; | |
| 15 | import { appGate, ttyRefusalText } from "./tty.ts"; | |
| 16 | ||
| 17 | /** A title as one safe line: control characters (escapes, newlines) gone, bounded. */ | |
| 18 | export function plainTitle(title: string): string { | |
| 19 | return title | |
| 20 | .replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, "") | |
| 21 | .trim() | |
| 22 | .slice(0, 120); | |
| 23 | } | |
| 24 | ||
| 25 | export function servePulls( | |
| 26 | forge: Forge, | |
| 27 | session: SshSession, | |
| 28 | user: User | undefined, | |
| 29 | target: { app: AppRecord; env: EnvName }, | |
| 30 | ): void { | |
| 31 | const refusal = appGate(forge, user, target); | |
| 32 | const repo = getRepoById(forge.db, target.app.repoId); | |
| 33 | if (refusal !== undefined || repo === undefined) { | |
| 34 | session.writeStderr( | |
| 35 | `forge: ${ttyRefusalText(refusal ?? "not_found", session.username)}\r\n`, | |
| 36 | ); | |
| 37 | session.exit(1); | |
| 38 | return; | |
| 39 | } | |
| 40 | const lines = openPulls(forge.db, repo, user).map( | |
| 41 | (p) => | |
| 42 | `#${p.number} ${plainTitle(p.title)} · ${checkWord(p.run, p.headOid)}\n`, | |
| 43 | ); | |
| 44 | session.write(new TextEncoder().encode(lines.join(""))); | |
| 45 | session.exit(0); | |
| 46 | } |
src/tui/ssh.ts +14
| @@ -26,6 +26,7 @@ import { serveSsh, type SshServer, type SshSession } from "../ssh/server.ts"; | ||
| 26 | 26 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 27 | 27 | import { createForgeApp } from "./forge.ts"; |
| 28 | 28 | import { appForLogin, attachAppTty } from "../apps/tty.ts"; |
| 29 | import { servePulls } from "../apps/tty_pulls.ts"; | |
| 29 | 30 | import type { Terminal } from "./term/terminal.ts"; |
| 30 | 31 | import type { TuiIdentity } from "./session.ts"; |
| 31 | 32 | |
| @@ -190,6 +191,12 @@ export function endOnSuspension( | ||
| 190 | 191 | }); |
| 191 | 192 | } |
| 192 | 193 | |
| 194 | /** The execs an app's login answers besides git, by command word. */ | |
| 195 | const appExecs: Record<string, typeof servePulls> = Object.assign( | |
| 196 | Object.create(null) as Record<string, typeof servePulls>, | |
| 197 | { pulls: servePulls }, | |
| 198 | ); | |
| 199 | ||
| 193 | 200 | /** |
| 194 | 201 | * What a forge does with an inbound SSH session: a shell is the TUI, an exec |
| 195 | 202 | * is git or nothing. Never throws — a fault here would take the listener with |
| @@ -210,6 +217,13 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void { | ||
| 210 | 217 | else attachTui(forge, session, identity); |
| 211 | 218 | return; |
| 212 | 219 | } |
| 220 | // An exec on an app's login that git does not speak: a small table. | |
| 221 | const door = appForLogin(forge, session.username); | |
| 222 | const verb = session.command === undefined ? undefined : appExecs[session.command.trim()]; | |
| 223 | if (door !== undefined && verb !== undefined) { | |
| 224 | verb(forge, session, identity.user, door); | |
| 225 | return; | |
| 226 | } | |
| 213 | 227 | void serveGitCommand(forge, session, identity.user).catch( |
| 214 | 228 | (error: unknown) => { |
| 215 | 229 | forge.log.error("git over ssh failed", { error: String(error) }); |
test/door-pulls.test.ts added +212
| @@ -0,0 +1,212 @@ | ||
| 1 | /** | |
| 2 | * `ssh <app>@<forge> pulls` (#6109): the module that reads an app's open | |
| 3 | * pulls for a viewer, and the exec that prints them, driven through | |
| 4 | * `forgeSshSession` with a fake session. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { newApp } from "../src/actions/apps.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { OPEN_PULLS_SQL, openPulls } from "../src/apps/door_pulls.ts"; | |
| 13 | import { addKey, fingerprint, parseAuthorizedKey } from "../src/core/keys.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { forgeSshSession } from "../src/tui/ssh.ts"; | |
| 17 | import { promote } from "./helpers/members.ts"; | |
| 18 | ||
| 19 | const KEYS = [ | |
| 20 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo bob", | |
| 21 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGq5xkQ8iVtfZ3pQ5m9X1Jw0m1dZf0QYbPzv6H7eYv2k eve", | |
| 22 | ]; | |
| 23 | ||
| 24 | describe("an app's open pulls over ssh", () => { | |
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let owner: User; | |
| 28 | let author: User; | |
| 29 | let stranger: User; | |
| 30 | let repo: Repo; | |
| 31 | let privateRepo: Repo; | |
| 32 | ||
| 33 | const person = async (username: string, adminIfFirst = false) => { | |
| 34 | const made = await createUser(app.db, { | |
| 35 | origin: "seed", | |
| 36 | trusted: true, | |
| 37 | username, | |
| 38 | email: `${username}@f.dev`, | |
| 39 | password: "a-good-passphrase", | |
| 40 | adminIfFirst, | |
| 41 | }); | |
| 42 | if (!made.ok) throw new Error(made.error); | |
| 43 | promote(app.db, made.value); | |
| 44 | return made.value; | |
| 45 | }; | |
| 46 | ||
| 47 | /** A pull row without a branch behind it: the module reads rows only. */ | |
| 48 | const pull = (r: Repo, n: number, by: User, title: string, sealed = 0) => { | |
| 49 | const id = crypto.randomUUID(); | |
| 50 | app.db | |
| 51 | .query( | |
| 52 | "INSERT INTO issue (id, repo_id, number, kind, title, author_id, sealed) VALUES (?, ?, ?, 'pull', ?, ?, ?)", | |
| 53 | ) | |
| 54 | .run(id, r.id, n, title, by.id, sealed); | |
| 55 | app.db | |
| 56 | .query( | |
| 57 | "INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', ?, 'b', ?)", | |
| 58 | ) | |
| 59 | .run(id, `h${n}`, `head${n}`); | |
| 60 | return id; | |
| 61 | }; | |
| 62 | ||
| 63 | const run = (issueId: string, r: Repo, state: string, commit: string) => | |
| 64 | app.db | |
| 65 | .query( | |
| 66 | "INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state) VALUES (?, ?, 'check', ?, ?, ?)", | |
| 67 | ) | |
| 68 | .run(crypto.randomUUID(), r.id, issueId, commit, state); | |
| 69 | ||
| 70 | /** Exec `pulls` on `login` as the holder of KEYS[i] (none: an unknown key). */ | |
| 71 | async function exec(login: string, key: number, command = "pulls") { | |
| 72 | const parsed = parseAuthorizedKey(KEYS[key]!); | |
| 73 | if (!parsed.ok) throw new Error("key"); | |
| 74 | let out = ""; | |
| 75 | let err = ""; | |
| 76 | let code: number | undefined; | |
| 77 | const closers: (() => void)[] = []; | |
| 78 | const session = { | |
| 79 | key: { fingerprint: fingerprint(parsed.value.blob), line: KEYS[key]! }, | |
| 80 | username: login, | |
| 81 | remoteAddress: "203.0.113.9", | |
| 82 | kind: "exec" as const, | |
| 83 | command, | |
| 84 | term: undefined, | |
| 85 | env: {}, | |
| 86 | cols: 0, | |
| 87 | rows: 0, | |
| 88 | widthPx: 0, | |
| 89 | heightPx: 0, | |
| 90 | ended: false, | |
| 91 | pending: 0, | |
| 92 | write: (b: Uint8Array) => void (out += new TextDecoder().decode(b)), | |
| 93 | writeStderr: (s: string | Uint8Array) => | |
| 94 | void (err += typeof s === "string" ? s : new TextDecoder().decode(s)), | |
| 95 | onData() {}, | |
| 96 | onResize() {}, | |
| 97 | onEof() {}, | |
| 98 | onClose: (cb: () => void) => void closers.push(cb), | |
| 99 | exit(c: number) { | |
| 100 | if (code !== undefined) return; | |
| 101 | code = c; | |
| 102 | for (const cb of closers) cb(); | |
| 103 | }, | |
| 104 | drained: async () => {}, | |
| 105 | }; | |
| 106 | forgeSshSession(app)(session as never); | |
| 107 | for (let i = 0; i < 50 && code === undefined; i++) await Bun.sleep(10); | |
| 108 | return { out, err, code }; | |
| 109 | } | |
| 110 | ||
| 111 | beforeAll(async () => { | |
| 112 | dataDir = await mkdtemp(join(tmpdir(), "forge-door-pulls-")); | |
| 113 | app = createApp({ dataDir, port: 0 }); | |
| 114 | owner = await person("olive", true); | |
| 115 | author = await person("bob"); | |
| 116 | stranger = await person("eve"); | |
| 117 | for (const [u, k] of [ | |
| 118 | [author, 0], | |
| 119 | [stranger, 1], | |
| 120 | ] as const) { | |
| 121 | const parsed = parseAuthorizedKey(KEYS[k]!); | |
| 122 | if (!parsed.ok) throw new Error("key"); | |
| 123 | expect(addKey(app.db, u.id, "k", parsed.value).ok).toBe(true); | |
| 124 | } | |
| 125 | const pub = await newApp(app, owner, { name: "hello", description: "", visibility: "public" }); | |
| 126 | if (!pub.ok) throw new Error(String(pub.error)); | |
| 127 | repo = pub.value.repo; | |
| 128 | const priv = await newApp(app, owner, { name: "secret", description: "", visibility: "private" }); | |
| 129 | if (!priv.ok) throw new Error(String(priv.error)); | |
| 130 | privateRepo = priv.value.repo; | |
| 131 | const ids: string[] = []; | |
| 132 | for (let n = 1; n <= 7; n++) ids.push(pull(repo, n, owner, `Pull ${n}`)); | |
| 133 | pull(repo, 8, author, "Sealed fix", 1); | |
| 134 | pull(privateRepo, 1, owner, "Hidden"); | |
| 135 | run(ids[6]!, repo, "ok", "head7"); | |
| 136 | run(ids[5]!, repo, "failed", "head6"); | |
| 137 | run(ids[4]!, repo, "ok", "old-head"); | |
| 138 | run(ids[3]!, repo, "running", "head4"); | |
| 139 | }); | |
| 140 | afterAll(async () => { | |
| 141 | app.close(); | |
| 142 | await rm(dataDir, { recursive: true, force: true }); | |
| 143 | }); | |
| 144 | ||
| 145 | test("the query walks an index: no scan of run, pull or issue", () => { | |
| 146 | const plan = app.db | |
| 147 | .query<{ detail: string }, [string, number, string, number]>(`EXPLAIN QUERY PLAN ${OPEN_PULLS_SQL}`) | |
| 148 | .all(repo.id, 0, "", 5) | |
| 149 | .map((r) => r.detail); | |
| 150 | for (const line of plan) | |
| 151 | expect(line).not.toMatch(/\bSCAN (run|pull|issue)\b(?! USING)/); | |
| 152 | expect(plan.join("\n")).toMatch(/USING (COVERING )?INDEX/); | |
| 153 | }); | |
| 154 | ||
| 155 | test("a guest sees the five newest unsealed pulls with their check words", async () => { | |
| 156 | const got = openPulls(app.db, repo, undefined); | |
| 157 | expect(got.map((p) => p.number)).toEqual([7, 6, 5, 4, 3]); | |
| 158 | const seen = await exec("hello", 1); | |
| 159 | // eve is a named user with no standing on the repo, which is public. | |
| 160 | expect(seen.out).toBe( | |
| 161 | [ | |
| 162 | "#7 Pull 7 · green", | |
| 163 | "#6 Pull 6 · red", | |
| 164 | "#5 Pull 5 · none", | |
| 165 | "#4 Pull 4 · running", | |
| 166 | "#3 Pull 3 · none", | |
| 167 | "", | |
| 168 | ].join("\n"), | |
| 169 | ); | |
| 170 | expect(seen.code).toBe(0); | |
| 171 | }); | |
| 172 | ||
| 173 | test("a sealed pull is its author's, a writer's and an admin's alone", async () => { | |
| 174 | expect(openPulls(app.db, repo, author, 10).map((p) => p.number)[0]).toBe(8); | |
| 175 | expect(openPulls(app.db, repo, owner, 10).map((p) => p.number)[0]).toBe(8); | |
| 176 | expect(openPulls(app.db, repo, stranger, 10).map((p) => p.number)[0]).toBe(7); | |
| 177 | expect(openPulls(app.db, repo, undefined, 10).map((p) => p.number)[0]).toBe(7); | |
| 178 | const mine = await exec("hello", 0); | |
| 179 | expect(mine.out.split("\n")[0]).toBe("#8 Sealed fix · none"); | |
| 180 | }); | |
| 181 | ||
| 182 | test("a suspended account counts as a guest", () => { | |
| 183 | const gone = { ...author, suspendedAt: "2026-01-01T00:00:00Z" }; | |
| 184 | expect(openPulls(app.db, repo, gone, 10).map((p) => p.number)[0]).toBe(7); | |
| 185 | }); | |
| 186 | ||
| 187 | test("a viewer who cannot read the repository gets nothing", () => { | |
| 188 | expect(openPulls(app.db, privateRepo, stranger)).toEqual([]); | |
| 189 | expect(openPulls(app.db, privateRepo, undefined)).toEqual([]); | |
| 190 | expect(openPulls(app.db, privateRepo, owner).map((p) => p.title)).toEqual(["Hidden"]); | |
| 191 | }); | |
| 192 | ||
| 193 | test("a named user who cannot read a private app is told there is none", async () => { | |
| 194 | const got = await exec("secret", 1); | |
| 195 | expect(got.err).toContain("no app named secret"); | |
| 196 | expect(got.out).toBe(""); | |
| 197 | expect(got.code).toBe(1); | |
| 198 | }); | |
| 199 | ||
| 200 | test("control characters in a title are stripped", async () => { | |
| 201 | pull(repo, 9, owner, "Evil\x1b[31m red\x07\nnext\r\x00line"); | |
| 202 | const got = await exec("hello", 1); | |
| 203 | expect(got.out.split("\n")[0]).toBe("#9 Evil[31m rednextline · none"); | |
| 204 | expect(got.out).not.toMatch(/[\x00-\x09\x0b-\x1f\x7f]/); | |
| 205 | }); | |
| 206 | ||
| 207 | test("other execs on an app's login are still git's", async () => { | |
| 208 | const got = await exec("hello", 1, "ls"); | |
| 209 | expect(got.err).toContain("only git is available"); | |
| 210 | expect(got.code).toBe(1); | |
| 211 | }); | |
| 212 | }); |
new above
offline