Public: anyone can read this; the crew can change it.
Four test files are red only when the whole suite runs: one file lowers a global and the next inherits it #310
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/302-four-test-files-are-red-only-when-the-wh → main 7 commits fork
32 files
changed +213
−69
src/kernel/log.ts +5
| @@ -42,6 +42,11 @@ export function setLogLevel(level: LogLevel): void { | ||
| 42 | 42 | threshold = level; |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | /** The floor as it stands, so whoever lowers it can put it back. */ | |
| 46 | export function logLevel(): LogLevel { | |
| 47 | return threshold; | |
| 48 | } | |
| 49 | ||
| 45 | 50 | export function createLog(scope: string): Log { |
| 46 | 51 | const emit = (level: LogLevel, message: string, fields?: LogFields) => { |
| 47 | 52 | if (order[level] < order[threshold]) return; |
src/origins/mirror.ts +6 −2
| @@ -59,12 +59,16 @@ export interface MirrorResult { | ||
| 59 | 59 | * `internal` Forgejo repo has no local analogue and becomes private: never |
| 60 | 60 | * silently widen a visibility on import. |
| 61 | 61 | */ |
| 62 | /** The forge's waits between tries of a new mirror's first fetch. */ | |
| 63 | export const FIRST_FETCH_RETRY_MS: readonly number[] = [2_000, 5_000, 10_000]; | |
| 64 | ||
| 62 | 65 | /** |
| 63 | 66 | * How long to wait between tries of a new mirror's first fetch. Mutable so a |
| 64 | * test that wants a failure now can have it now; the forge keeps the default. | |
| 67 | * test that wants a failure now can have it now; the forge keeps the default, | |
| 68 | * and a test that lowers it puts back what it found. | |
| 65 | 69 | */ |
| 66 | 70 | export const firstFetchRetry = { |
| 67 | ms: [2_000, 5_000, 10_000] as readonly number[], | |
| 71 | ms: FIRST_FETCH_RETRY_MS, | |
| 68 | 72 | }; |
| 69 | 73 | |
| 70 | 74 | export async function mirrorRepo( |
test/agents.test.ts +2 −2
| @@ -30,11 +30,11 @@ import { | ||
| 30 | 30 | import { RateLimiter } from "../src/core/ratelimit.ts"; |
| 31 | 31 | import { deliveryBusy, runDelivery } from "../src/core/webhooks.ts"; |
| 32 | 32 | import { Gate, gitGate, runGit } from "../src/git/env.ts"; |
| 33 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 33 | import { quietLogs } from "./helpers/globals.ts"; | |
| 34 | 34 | import { isBusy, transact } from "../src/kernel/db.ts"; |
| 35 | 35 | import { repoDir } from "../src/git/repos.ts"; |
| 36 | 36 | |
| 37 | setLogLevel("error"); | |
| 37 | quietLogs(); | |
| 38 | 38 | |
| 39 | 39 | const WRITERS = 50; |
| 40 | 40 |
test/app-env.test.ts +2 −2
| @@ -25,9 +25,9 @@ import { | ||
| 25 | 25 | type AppRecord, |
| 26 | 26 | } from "../src/core/apps.ts"; |
| 27 | 27 | import { createUser } from "../src/core/users.ts"; |
| 28 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 28 | import { quietLogs } from "./helpers/globals.ts"; | |
| 29 | 29 | |
| 30 | setLogLevel("error"); | |
| 30 | quietLogs(); | |
| 31 | 31 | |
| 32 | 32 | describe("an app's environment", () => { |
| 33 | 33 | let dataDir = ""; |
test/git-ssh.test.ts +2 −2
| @@ -27,7 +27,7 @@ import { getRepo } from "../src/core/repos.ts"; | ||
| 27 | 27 | import type { User } from "../src/core/users.ts"; |
| 28 | 28 | import { runGit } from "../src/git/env.ts"; |
| 29 | 29 | import { repoDir } from "../src/git/repos.ts"; |
| 30 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 30 | import { quietLogs } from "./helpers/globals.ts"; | |
| 31 | 31 | import { runGitService, type GitChannel } from "../src/git/smart-ssh.ts"; |
| 32 | 32 | import { serveForgeSsh } from "../src/tui/ssh.ts"; |
| 33 | 33 | import type { SshServer } from "../src/ssh/server.ts"; |
| @@ -35,7 +35,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 35 | 35 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 36 | 36 | import { missing } from "./helpers/tools.ts"; |
| 37 | 37 | |
| 38 | setLogLevel("error"); | |
| 38 | quietLogs(); | |
| 39 | 39 | |
| 40 | 40 | const SSH_KEYGEN = "/usr/bin/ssh-keygen"; |
| 41 | 41 | /** The client half is the real OpenSSH; without it these skip, loudly. */ |
test/helpers.test.ts +57 −5
| @@ -1,15 +1,19 @@ | ||
| 1 | 1 | /** |
| 2 | * The two helpers the suite's own health rests on: the environment a git | |
| 3 | * client under test runs in, and whether a tool this machine would need is | |
| 4 | * here. Both exist because of a run that failed for the machine's reasons | |
| 5 | * and was read as the change's fault (#233). | |
| 2 | * The helpers the suite's own health rests on: the environment a git client | |
| 3 | * under test runs in, whether a tool this machine would need is here, and | |
| 4 | * the process globals a file borrows. Each exists because of a run that | |
| 5 | * failed for the machine's reasons, or another file's, and was read as the | |
| 6 | * change's fault (#233, #302). | |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | import { describe, expect, test } from "bun:test"; | |
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| 11 | 12 | import { join } from "node:path"; |
| 12 | 13 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 14 | import { logLevel, setLogLevel, type LogLevel } from "../src/kernel/log.ts"; | |
| 15 | import { firstFetchRetry } from "../src/origins/mirror.ts"; | |
| 16 | import { fastFirstFetch, quietLogs, withoutEnv } from "./helpers/globals.ts"; | |
| 13 | 17 | import { missing } from "./helpers/tools.ts"; |
| 14 | 18 | |
| 15 | 19 | describe("a git client's environment under test", () => { |
| @@ -75,3 +79,51 @@ describe("whether a tool is on this machine", () => { | ||
| 75 | 79 | expect(missing("sh", "no-such-tool-on-any-machine")).toBe(true); |
| 76 | 80 | }); |
| 77 | 81 | }); |
| 82 | ||
| 83 | describe("a global a file borrows is given back", () => { | |
| 84 | // Values nobody else would pick, so reading them back proves the restore | |
| 85 | // rather than a coincidence with the defaults. | |
| 86 | const ASIDE = "https://machine.example.test"; | |
| 87 | const WAITS: readonly number[] = [1, 2, 3]; | |
| 88 | let found: { | |
| 89 | level: LogLevel; | |
| 90 | waits: readonly number[]; | |
| 91 | url: string | undefined; | |
| 92 | }; | |
| 93 | beforeAll(() => { | |
| 94 | found = { | |
| 95 | level: logLevel(), | |
| 96 | waits: firstFetchRetry.ms, | |
| 97 | url: process.env.FORGE_URL, | |
| 98 | }; | |
| 99 | setLogLevel("warn"); | |
| 100 | firstFetchRetry.ms = WAITS; | |
| 101 | process.env.FORGE_URL = ASIDE; | |
| 102 | }); | |
| 103 | afterAll(() => { | |
| 104 | setLogLevel(found.level); | |
| 105 | firstFetchRetry.ms = found.waits; | |
| 106 | if (found.url === undefined) delete process.env.FORGE_URL; | |
| 107 | else process.env.FORGE_URL = found.url; | |
| 108 | }); | |
| 109 | ||
| 110 | describe("while a file borrows them", () => { | |
| 111 | quietLogs(); | |
| 112 | fastFirstFetch([50]); | |
| 113 | withoutEnv("FORGE_URL"); | |
| 114 | ||
| 115 | test("they hold what it asked for", () => { | |
| 116 | expect(logLevel()).toBe("error"); | |
| 117 | expect(firstFetchRetry.ms).toEqual([50]); | |
| 118 | expect(process.env.FORGE_URL).toBeUndefined(); | |
| 119 | // What the file sets under a cleared name goes when it does. | |
| 120 | process.env.FORGE_URL = "https://set.inside.test"; | |
| 121 | }); | |
| 122 | }); | |
| 123 | ||
| 124 | test("afterwards they read back what they were", () => { | |
| 125 | expect(logLevel()).toBe("warn"); | |
| 126 | expect(firstFetchRetry.ms).toBe(WAITS); | |
| 127 | expect(process.env.FORGE_URL).toBe(ASIDE); | |
| 128 | }); | |
| 129 | }); |
test/helpers/globals.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** | |
| 2 | * Borrowing a process global for one test file, and giving it back. The | |
| 3 | * suite runs every file in one process, so a floor lowered or a variable | |
| 4 | * cleared and never restored is inherited by whichever file runs next — and | |
| 5 | * that file goes red for a reason that belongs to this one (#302). Each | |
| 6 | * helper registers its own `beforeAll`/`afterAll` in the scope it is called | |
| 7 | * from: called at a file's top level, it holds for that file and no longer. | |
| 8 | */ | |
| 9 | ||
| 10 | import { afterAll, beforeAll } from "bun:test"; | |
| 11 | import { logLevel, setLogLevel, type LogLevel } from "../../src/kernel/log.ts"; | |
| 12 | import { firstFetchRetry } from "../../src/origins/mirror.ts"; | |
| 13 | ||
| 14 | /** Raise the log floor (by default to errors only) while this scope runs. */ | |
| 15 | export function quietLogs(level: LogLevel = "error"): void { | |
| 16 | let found: LogLevel; | |
| 17 | beforeAll(() => { | |
| 18 | found = logLevel(); | |
| 19 | setLogLevel(level); | |
| 20 | }); | |
| 21 | afterAll(() => setLogLevel(found)); | |
| 22 | } | |
| 23 | ||
| 24 | /** Shorten a new mirror's first-fetch retries while this scope runs. */ | |
| 25 | export function fastFirstFetch(ms: readonly number[] = [50]): void { | |
| 26 | let found: readonly number[]; | |
| 27 | beforeAll(() => { | |
| 28 | found = firstFetchRetry.ms; | |
| 29 | firstFetchRetry.ms = ms; | |
| 30 | }); | |
| 31 | afterAll(() => { | |
| 32 | firstFetchRetry.ms = found; | |
| 33 | }); | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Unset environment variables the machine may carry while this scope runs. | |
| 38 | * A crew machine sets `FORGE_URL` to its own forge; a test that registers an | |
| 39 | * OAuth callback must not inherit it. Whatever the scope itself sets under | |
| 40 | * these names is cleared again on the way out, and the machine's values put | |
| 41 | * back. | |
| 42 | */ | |
| 43 | export function withoutEnv(...names: string[]): void { | |
| 44 | const found = new Map<string, string | undefined>(); | |
| 45 | beforeAll(() => { | |
| 46 | for (const name of names) { | |
| 47 | found.set(name, process.env[name]); | |
| 48 | delete process.env[name]; | |
| 49 | } | |
| 50 | }); | |
| 51 | afterAll(() => { | |
| 52 | for (const [name, value] of found) { | |
| 53 | if (value === undefined) delete process.env[name]; | |
| 54 | else process.env[name] = value; | |
| 55 | } | |
| 56 | }); | |
| 57 | } |
test/looks.test.ts +2 −2
| @@ -31,7 +31,7 @@ import { listRuns, type Run } from "../src/core/runs.ts"; | ||
| 31 | 31 | import { createUser, type User } from "../src/core/users.ts"; |
| 32 | 32 | import { serve, type Servers } from "../src/http/server.ts"; |
| 33 | 33 | import { err, ok } from "../src/kernel/result.ts"; |
| 34 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 34 | import { quietLogs } from "./helpers/globals.ts"; | |
| 35 | 35 | import { |
| 36 | 36 | findChromium, |
| 37 | 37 | takeLook, |
| @@ -40,7 +40,7 @@ import { | ||
| 40 | 40 | } from "../src/looks/capture.ts"; |
| 41 | 41 | import { startLooks, type LooksHandle } from "../src/looks/service.ts"; |
| 42 | 42 | |
| 43 | setLogLevel("error"); | |
| 43 | quietLogs(); | |
| 44 | 44 | |
| 45 | 45 | const DOMAIN = "apps.test"; |
| 46 | 46 | const PASSWORD = "a-good-passphrase"; |
test/origins-oauth.test.ts +5
| @@ -25,6 +25,7 @@ import { | ||
| 25 | 25 | type FakeForge, |
| 26 | 26 | type FakeOAuth, |
| 27 | 27 | } from "./fake-origin.ts"; |
| 28 | import { withoutEnv } from "./helpers/globals.ts"; | |
| 28 | 29 | |
| 29 | 30 | /** |
| 30 | 31 | * Connecting an origin without pasting a token, and the security properties |
| @@ -83,6 +84,10 @@ async function shutdown(f: Fixture): Promise<void> { | ||
| 83 | 84 | await rm(f.dataDir, { recursive: true, force: true }); |
| 84 | 85 | } |
| 85 | 86 | |
| 87 | // The code flow's redirect_uri is this test server's own — never the forge a | |
| 88 | // crew machine's `FORGE_URL` names. | |
| 89 | withoutEnv("FORGE_URL"); | |
| 90 | ||
| 86 | 91 | // ---------------------------------------------------------------- device |
| 87 | 92 | |
| 88 | 93 | describe("GitHub's device grant: connecting with no browser on the server", () => { |
test/origins-security-auth.test.ts +7 −4
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 2 | import { firstFetchRetry } from "../src/origins/mirror.ts"; | |
| 3 | 2 | import { mkdtemp, readFile, rm } from "node:fs/promises"; |
| 4 | 3 | import { tmpdir } from "node:os"; |
| 5 | 4 | import { join } from "node:path"; |
| @@ -23,6 +22,7 @@ import { | ||
| 23 | 22 | type FakeForge, |
| 24 | 23 | type FakeOAuth, |
| 25 | 24 | } from "./fake-origin.ts"; |
| 25 | import { fastFirstFetch, withoutEnv } from "./helpers/globals.ts"; | |
| 26 | 26 | |
| 27 | 27 | /** |
| 28 | 28 | * Adversarial review of the origins surface: OAuth (state, PKCE, redirect |
| @@ -100,6 +100,12 @@ async function mintToken( | ||
| 100 | 100 | const originCount = (app: App): number => |
| 101 | 101 | app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM origin").get()!.n; |
| 102 | 102 | |
| 103 | // A fetch that fails should say so now, not after the forge's patience. | |
| 104 | fastFirstFetch(); | |
| 105 | // The code flow's redirect_uri is this test server's own, unless a test says | |
| 106 | // otherwise — never the forge a crew machine's `FORGE_URL` names. | |
| 107 | withoutEnv("FORGE_URL"); | |
| 108 | ||
| 103 | 109 | // ------------------------------------------------------------- code flow |
| 104 | 110 | |
| 105 | 111 | describe("OAuth code flow: state, PKCE, redirects, and the callback URI", () => { |
| @@ -134,8 +140,6 @@ describe("OAuth code flow: state, PKCE, redirects, and the callback URI", () => | ||
| 134 | 140 | .length; |
| 135 | 141 | |
| 136 | 142 | beforeAll(async () => { |
| 137 | // A fetch that fails should say so now, not after the forge's patience. | |
| 138 | firstFetchRetry.ms = [50]; | |
| 139 | 143 | dataDir = await mkdtemp(join(tmpdir(), "forge-sec-code-")); |
| 140 | 144 | const state = newFakeState("forgejo", LOGIN, ORIGIN_TOKEN); |
| 141 | 145 | oauth = newFakeOAuth(CLIENT_ID); |
| @@ -154,7 +158,6 @@ describe("OAuth code flow: state, PKCE, redirects, and the callback URI", () => | ||
| 154 | 158 | }); |
| 155 | 159 | }); |
| 156 | 160 | afterAll(async () => { |
| 157 | delete process.env.FORGE_URL; | |
| 158 | 161 | fake.stop(); |
| 159 | 162 | servers.stop(); |
| 160 | 163 | app.close(); |
test/scale.test.ts +2 −2
| @@ -51,10 +51,10 @@ import { | ||
| 51 | 51 | sweepReaderPool, |
| 52 | 52 | closeReader, |
| 53 | 53 | } from "../src/git/read.ts"; |
| 54 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 54 | import { quietLogs } from "./helpers/globals.ts"; | |
| 55 | 55 | import type { User } from "../src/core/users.ts"; |
| 56 | 56 | |
| 57 | setLogLevel("error"); | |
| 57 | quietLogs(); | |
| 58 | 58 | |
| 59 | 59 | /** The `detail` lines EXPLAIN QUERY PLAN produces for a statement. */ |
| 60 | 60 | function plan(app: App, sql: string, params: (string | number)[] = []): string { |
test/ssh-app.test.ts +2 −2
| @@ -14,7 +14,7 @@ import { join } from "node:path"; | ||
| 14 | 14 | import { createRepo } from "../src/actions/repos.ts"; |
| 15 | 15 | import { addSshKey, signUp } from "../src/actions/users.ts"; |
| 16 | 16 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 17 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | 18 | import { Channels, type SshSession } from "../src/ssh/channels.ts"; |
| 19 | 19 | import { MSG, Writer } from "../src/ssh/wire.ts"; |
| 20 | 20 | import { createLog } from "../src/kernel/log.ts"; |
| @@ -22,7 +22,7 @@ import { serveForgeSsh } from "../src/tui/ssh.ts"; | ||
| 22 | 22 | import type { SshServer } from "../src/ssh/server.ts"; |
| 23 | 23 | import { missing } from "./helpers/tools.ts"; |
| 24 | 24 | |
| 25 | setLogLevel("error"); | |
| 25 | quietLogs(); | |
| 26 | 26 | const log = createLog("ssh-app-test"); |
| 27 | 27 | |
| 28 | 28 | const SSH = "/usr/bin/ssh"; |
test/ssh.test.ts +3 −2
| @@ -14,7 +14,8 @@ import { | ||
| 14 | 14 | import { mkdtempSync, readFileSync, rmSync } from "node:fs"; |
| 15 | 15 | import { tmpdir } from "node:os"; |
| 16 | 16 | import { join } from "node:path"; |
| 17 | import { createLog, setLogLevel } from "../src/kernel/log.ts"; | |
| 17 | import { createLog } from "../src/kernel/log.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | 19 | import { Channels } from "../src/ssh/channels.ts"; |
| 19 | 20 | import { MSG, WireError, Writer } from "../src/ssh/wire.ts"; |
| 20 | 21 | import { |
| @@ -36,7 +37,7 @@ let root = ""; | ||
| 36 | 37 | const servers: SshServer[] = []; |
| 37 | 38 | const keys: Record<string, string> = {}; |
| 38 | 39 | |
| 39 | setLogLevel("error"); | |
| 40 | quietLogs(); | |
| 40 | 41 | |
| 41 | 42 | function sha256(data: Uint8Array): string { |
| 42 | 43 | return createHash("sha256").update(data).digest("hex"); |
test/surfaces-adversarial.test.ts +2 −2
| @@ -28,7 +28,7 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 28 | 28 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 29 | 29 | import { serve, type Servers } from "../src/http/server.ts"; |
| 30 | 30 | import { id } from "../src/kernel/id.ts"; |
| 31 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 31 | import { quietLogs } from "./helpers/globals.ts"; | |
| 32 | 32 | import { appendRunLog, runLogListenerCount } from "../src/runs/log.ts"; |
| 33 | 33 | import { content as contentStyle } from "../src/web/style/content.ts"; |
| 34 | 34 | import { runScreen } from "../src/tui/screens/run.ts"; |
| @@ -41,7 +41,7 @@ import type { App as TuiApp, Screen } from "../src/tui/app/app.ts"; | ||
| 41 | 41 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 42 | 42 | import { settleApp } from "./helpers/tui.ts"; |
| 43 | 43 | |
| 44 | setLogLevel("error"); | |
| 44 | quietLogs(); | |
| 45 | 45 | |
| 46 | 46 | /** Count the SQL statements a block of work runs — mirrors test/counts.test.ts. */ |
| 47 | 47 | async function statements(db: Database, work: () => Promise<void> | void) { |
test/tui-apps.test.ts +2 −2
| @@ -38,7 +38,7 @@ import { | ||
| 38 | 38 | } from "../src/core/runs.ts"; |
| 39 | 39 | import { createUser, type User } from "../src/core/users.ts"; |
| 40 | 40 | import { serve, type Servers } from "../src/http/server.ts"; |
| 41 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 41 | import { quietLogs } from "./helpers/globals.ts"; | |
| 42 | 42 | import { appendRunLog } from "../src/runs/log.ts"; |
| 43 | 43 | import type { App, Screen } from "../src/tui/app/app.ts"; |
| 44 | 44 | import { createForgeApp } from "../src/tui/forge.ts"; |
| @@ -54,7 +54,7 @@ import { | ||
| 54 | 54 | } from "../src/tui/term/terminal.ts"; |
| 55 | 55 | import { settleApp } from "./helpers/tui.ts"; |
| 56 | 56 | |
| 57 | setLogLevel("error"); | |
| 57 | quietLogs(); | |
| 58 | 58 | |
| 59 | 59 | const DOMAIN = "apps.test"; |
| 60 | 60 | const NOT_AN_APP = "this commit is not an app"; |
test/tui-code.test.ts +2 −2
| @@ -25,7 +25,7 @@ import { createApp, type App as Forge } from "../src/app.ts"; | ||
| 25 | 25 | import { listBranches } from "../src/git/branches.ts"; |
| 26 | 26 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 27 | 27 | import type { User } from "../src/core/users.ts"; |
| 28 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 28 | import { quietLogs } from "./helpers/globals.ts"; | |
| 29 | 29 | import { App } from "../src/tui/app/app.ts"; |
| 30 | 30 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 31 | 31 | import type { TuiIdentity } from "../src/tui/session.ts"; |
| @@ -33,7 +33,7 @@ import { headlessTerminal } from "../src/tui/term/terminal.ts"; | ||
| 33 | 33 | import { indexPaths, rankPaths } from "../src/tui/ui/fuzzy.ts"; |
| 34 | 34 | import { settleApp, settleUntil } from "./helpers/tui.ts"; |
| 35 | 35 | |
| 36 | setLogLevel("error"); | |
| 36 | quietLogs(); | |
| 37 | 37 | |
| 38 | 38 | interface Harness { |
| 39 | 39 | app: App; |
test/tui-editor.test.ts +2 −2
| @@ -25,7 +25,7 @@ import { getPull } from "../src/core/pulls.ts"; | ||
| 25 | 25 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 26 | 26 | import type { User } from "../src/core/users.ts"; |
| 27 | 27 | import { blobForRender } from "../src/git/read.ts"; |
| 28 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 28 | import { quietLogs } from "./helpers/globals.ts"; | |
| 29 | 29 | import { App } from "../src/tui/app/app.ts"; |
| 30 | 30 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 31 | 31 | import { forgetEditor } from "../src/tui/screens/external.ts"; |
| @@ -34,7 +34,7 @@ import { headlessTerminal } from "../src/tui/term/terminal.ts"; | ||
| 34 | 34 | import { Editor, colFromDisplay, displayCol } from "../src/tui/ui/editor.ts"; |
| 35 | 35 | import { settleApp, settleUntil } from "./helpers/tui.ts"; |
| 36 | 36 | |
| 37 | setLogLevel("error"); | |
| 37 | quietLogs(); | |
| 38 | 38 | |
| 39 | 39 | const bytes = (s: string): Uint8Array => new TextEncoder().encode(s); |
| 40 | 40 | const text = (b: Uint8Array): string => new TextDecoder().decode(b); |
test/tui-icons.test.ts +2 −2
| @@ -22,7 +22,7 @@ import { signUp } from "../src/actions/users.ts"; | ||
| 22 | 22 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 23 | 23 | import { getUserSetting } from "../src/core/settings.ts"; |
| 24 | 24 | import type { User } from "../src/core/users.ts"; |
| 25 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | 26 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 27 | 27 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 28 | 28 | import { |
| @@ -35,7 +35,7 @@ import { | ||
| 35 | 35 | import { clusterWidth, clusters, displayWidth } from "../src/tui/term/width.ts"; |
| 36 | 36 | import { settleApp, settleUntil } from "./helpers/tui.ts"; |
| 37 | 37 | |
| 38 | setLogLevel("error"); | |
| 38 | quietLogs(); | |
| 39 | 39 | |
| 40 | 40 | const SETS = ["plain", "nerd"] as const; |
| 41 | 41 |
test/tui-mouse.test.ts +2 −2
| @@ -22,7 +22,7 @@ import { signUp } from "../src/actions/users.ts"; | ||
| 22 | 22 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 23 | 23 | import type { Repo } from "../src/core/repos.ts"; |
| 24 | 24 | import type { User } from "../src/core/users.ts"; |
| 25 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | 26 | import type { App } from "../src/tui/app/app.ts"; |
| 27 | 27 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 28 | 28 | import type { TuiIdentity } from "../src/tui/session.ts"; |
| @@ -32,7 +32,7 @@ import { | ||
| 32 | 32 | } from "../src/tui/term/terminal.ts"; |
| 33 | 33 | import { settleApp, settleUntil } from "./helpers/tui.ts"; |
| 34 | 34 | |
| 35 | setLogLevel("error"); | |
| 35 | quietLogs(); | |
| 36 | 36 | |
| 37 | 37 | interface Harness { |
| 38 | 38 | app: App; |
test/tui-origins.test.ts +2 −2
| @@ -21,7 +21,7 @@ import { listOrigins } from "../src/core/origins.ts"; | ||
| 21 | 21 | import { listPins } from "../src/core/pins.ts"; |
| 22 | 22 | import { getRepo } from "../src/core/repos.ts"; |
| 23 | 23 | import type { User } from "../src/core/users.ts"; |
| 24 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | 25 | import { forgetProviders } from "../src/origins/providers/index.ts"; |
| 26 | 26 | import type { App } from "../src/tui/app/app.ts"; |
| 27 | 27 | import { createForgeApp } from "../src/tui/forge.ts"; |
| @@ -39,7 +39,7 @@ import { | ||
| 39 | 39 | type FakeOAuth, |
| 40 | 40 | } from "./fake-origin.ts"; |
| 41 | 41 | |
| 42 | setLogLevel("error"); | |
| 42 | quietLogs(); | |
| 43 | 43 | |
| 44 | 44 | const ORIGIN_LOGIN = "octo"; |
| 45 | 45 | const ORIGIN_TOKEN = "octo-origin-secret-9f2a"; |
test/tui-screens.test.ts +2 −2
| @@ -29,7 +29,7 @@ import { getPull } from "../src/core/pulls.ts"; | ||
| 29 | 29 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 30 | 30 | import { getUserSetting } from "../src/core/settings.ts"; |
| 31 | 31 | import { getUserByName, type User } from "../src/core/users.ts"; |
| 32 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 32 | import { quietLogs } from "./helpers/globals.ts"; | |
| 33 | 33 | import { parseGitCommand, splitCommand } from "../src/git/smart-ssh.ts"; |
| 34 | 34 | import { App } from "../src/tui/app/app.ts"; |
| 35 | 35 | import { createForgeApp } from "../src/tui/forge.ts"; |
| @@ -39,7 +39,7 @@ import { headlessTerminal } from "../src/tui/term/terminal.ts"; | ||
| 39 | 39 | import type { SshSession } from "../src/ssh/channels.ts"; |
| 40 | 40 | import { settleApp } from "./helpers/tui.ts"; |
| 41 | 41 | |
| 42 | setLogLevel("error"); | |
| 42 | quietLogs(); | |
| 43 | 43 | |
| 44 | 44 | // A real ed25519 authorized_keys line, so key parsing is exercised for real. |
| 45 | 45 | const KEY_LINE = |
test/tui-size.test.ts +2 −2
| @@ -23,7 +23,7 @@ import { createApp, type App as Forge } from "../src/app.ts"; | ||
| 23 | 23 | import type { Repo } from "../src/core/repos.ts"; |
| 24 | 24 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 25 | 25 | import type { User } from "../src/core/users.ts"; |
| 26 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | 27 | import { appendRunLog } from "../src/runs/log.ts"; |
| 28 | 28 | import type { App, Screen } from "../src/tui/app/app.ts"; |
| 29 | 29 | import { createForgeApp } from "../src/tui/forge.ts"; |
| @@ -49,7 +49,7 @@ import { theme } from "../src/tui/ui/theme.ts"; | ||
| 49 | 49 | import { row, table } from "../src/tui/ui/widgets.ts"; |
| 50 | 50 | import { settleApp } from "./helpers/tui.ts"; |
| 51 | 51 | |
| 52 | setLogLevel("error"); | |
| 52 | quietLogs(); | |
| 53 | 53 | |
| 54 | 54 | const SIZES: readonly [number, number][] = [ |
| 55 | 55 | [60, 20], |
test/tui-workflows.test.ts +20 −2
| @@ -35,7 +35,7 @@ import { listReviews } from "../src/core/reviews.ts"; | ||
| 35 | 35 | import { getPull } from "../src/core/pulls.ts"; |
| 36 | 36 | import type { User } from "../src/core/users.ts"; |
| 37 | 37 | import { serve, type Servers } from "../src/http/server.ts"; |
| 38 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 38 | import { quietLogs } from "./helpers/globals.ts"; | |
| 39 | 39 | import { forgetProviders } from "../src/origins/providers/index.ts"; |
| 40 | 40 | import type { App } from "../src/tui/app/app.ts"; |
| 41 | 41 | import { createForgeApp } from "../src/tui/forge.ts"; |
| @@ -47,7 +47,7 @@ import { | ||
| 47 | 47 | import { settleApp } from "./helpers/tui.ts"; |
| 48 | 48 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; |
| 49 | 49 | |
| 50 | setLogLevel("error"); | |
| 50 | quietLogs(); | |
| 51 | 51 | |
| 52 | 52 | interface Harness { |
| 53 | 53 | app: App; |
| @@ -61,6 +61,13 @@ interface Harness { | ||
| 61 | 61 | settle(): Promise<void>; |
| 62 | 62 | /** Pump until the screen says something, or give up loudly. */ |
| 63 | 63 | until(what: string, ms?: number): Promise<void>; |
| 64 | /** | |
| 65 | * The title of every screen pushed since boot, in order. A card that | |
| 66 | * comes down the moment its work is done — the mirror card over a small | |
| 67 | * repository — can be up and gone between two looks at the screen; this | |
| 68 | * is how a test says it was shown without racing it. | |
| 69 | */ | |
| 70 | opened(): string[]; | |
| 64 | 71 | /** Press one key until the screen says something — for walking a viewport. */ |
| 65 | 72 | walk(key: string, what: string, limit?: number): Promise<void>; |
| 66 | 73 | stop(): void; |
| @@ -77,6 +84,12 @@ function boot( | ||
| 77 | 84 | terminal, |
| 78 | 85 | exitHooks: false, |
| 79 | 86 | }); |
| 87 | const opened: string[] = []; | |
| 88 | const push = app.push.bind(app); | |
| 89 | app.push = (screen) => { | |
| 90 | opened.push(screen.title); | |
| 91 | push(screen); | |
| 92 | }; | |
| 80 | 93 | app.start(); |
| 81 | 94 | const settle = (): Promise<void> => settleApp(app); |
| 82 | 95 | const grid = (): string[] => app.screen.toText().split("\n"); |
| @@ -123,6 +136,7 @@ function boot( | ||
| 123 | 136 | await settle(); |
| 124 | 137 | }, |
| 125 | 138 | settle, |
| 139 | opened: () => [...opened], | |
| 126 | 140 | async until(what, ms = 30_000) { |
| 127 | 141 | const deadline = Date.now() + ms; |
| 128 | 142 | for (;;) { |
| @@ -390,6 +404,8 @@ describe("workflows at an origin", () => { | ||
| 390 | 404 | // outcome — the fork's own workspace — rather than the mirror card, |
| 391 | 405 | // which can finish and be gone before the next poll ever sees it. |
| 392 | 406 | await h.until(`fork of ${UPSTREAM}/gears`, 60_000); |
| 407 | // The card was still shown: the harness saw it pushed. | |
| 408 | expect(h.opened().some((t) => t.startsWith("Mirroring"))).toBe(true); | |
| 393 | 409 | |
| 394 | 410 | const fork = getRepo(forge.db, "alice", "gears"); |
| 395 | 411 | expect(fork).toBeDefined(); |
| @@ -421,6 +437,7 @@ describe("workflows at an origin", () => { | ||
| 421 | 437 | // job can finish before the next poll ever sees "Mirroring". |
| 422 | 438 | await h.until("mirrored as alice/widgets", 60_000); |
| 423 | 439 | expect(getRepo(forge.db, "alice", "widgets")).toBeDefined(); |
| 440 | expect(h.opened()).toContain(`Mirroring ${UPSTREAM}/widgets`); | |
| 424 | 441 | |
| 425 | 442 | // Now browse it live: read-only, and not ours to push to. |
| 426 | 443 | await h.press("enter"); |
| @@ -485,6 +502,7 @@ describe("workflows at an origin", () => { | ||
| 485 | 502 | // Wait for the outcome, not the mirror card — see the fork test above. |
| 486 | 503 | await h.press("m"); |
| 487 | 504 | await h.until("mirrored as alice/own", 60_000); |
| 505 | expect(h.opened()).toContain(`Mirroring ${ORIGIN_LOGIN}/own`); | |
| 488 | 506 | await h.press("enter"); |
| 489 | 507 | await h.until("read live"); |
| 490 | 508 | // The origin says this one is ours to push to, so nothing is proposed. |
test/tui-workspace.test.ts +2 −2
| @@ -26,7 +26,7 @@ import { listPulls } from "../src/core/pulls.ts"; | ||
| 26 | 26 | import type { User } from "../src/core/users.ts"; |
| 27 | 27 | import { blobForRender } from "../src/git/read.ts"; |
| 28 | 28 | import { listBranches } from "../src/git/branches.ts"; |
| 29 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 29 | import { quietLogs } from "./helpers/globals.ts"; | |
| 30 | 30 | import { App } from "../src/tui/app/app.ts"; |
| 31 | 31 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 32 | 32 | import type { TuiIdentity } from "../src/tui/session.ts"; |
| @@ -36,7 +36,7 @@ import { | ||
| 36 | 36 | } from "../src/tui/term/terminal.ts"; |
| 37 | 37 | import { settleApp, settleUntil } from "./helpers/tui.ts"; |
| 38 | 38 | |
| 39 | setLogLevel("error"); | |
| 39 | quietLogs(); | |
| 40 | 40 | |
| 41 | 41 | /** |
| 42 | 42 | * The colour the first `export` keyword is drawn in. A theme switch has to |
test/view-source.test.ts +4 −3
| @@ -6,7 +6,6 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 6 | 6 | import { serve, type Servers } from "../src/http/server.ts"; |
| 7 | 7 | import { resetConnectLimit } from "../src/actions/origins.ts"; |
| 8 | 8 | import { getUserByName, type User } from "../src/core/users.ts"; |
| 9 | import { firstFetchRetry } from "../src/origins/mirror.ts"; | |
| 10 | 9 | import { settled, subscribe, type MirrorStage } from "../src/origins/jobs.ts"; |
| 11 | 10 | import { forgetProviders } from "../src/origins/providers/index.ts"; |
| 12 | 11 | import { |
| @@ -22,6 +21,7 @@ import { | ||
| 22 | 21 | serveFakeForge, |
| 23 | 22 | type FakeForge, |
| 24 | 23 | } from "./fake-origin.ts"; |
| 24 | import { fastFirstFetch } from "./helpers/globals.ts"; | |
| 25 | 25 | |
| 26 | 26 | /** |
| 27 | 27 | * Browsing before mirroring. |
| @@ -53,6 +53,9 @@ const read = async <T>(response: Response): Promise<T> => | ||
| 53 | 53 | |
| 54 | 54 | const text = (body: Uint8Array): string => new TextDecoder().decode(body); |
| 55 | 55 | |
| 56 | // A fetch that fails should say so now, not after the forge's patience. | |
| 57 | fastFirstFetch(); | |
| 58 | ||
| 56 | 59 | describe("RepoSource: one repository, two places to read it from", () => { |
| 57 | 60 | let dataDir: string; |
| 58 | 61 | let originDataDir: string; |
| @@ -97,8 +100,6 @@ describe("RepoSource: one repository, two places to read it from", () => { | ||
| 97 | 100 | const requests = (): number => fake.state.requests.length; |
| 98 | 101 | |
| 99 | 102 | beforeAll(async () => { |
| 100 | // A fetch that fails should say so now, not after the forge's patience. | |
| 101 | firstFetchRetry.ms = [50]; | |
| 102 | 103 | dataDir = await mkdtemp(join(tmpdir(), "forge-view-")); |
| 103 | 104 | originDataDir = await mkdtemp(join(tmpdir(), "forge-view-up-")); |
| 104 | 105 |
test/web-apps.test.ts +2 −2
| @@ -29,9 +29,9 @@ import { getRepo, type Repo } from "../src/core/repos.ts"; | ||
| 29 | 29 | import { isTerminal, listRuns, type Run } from "../src/core/runs.ts"; |
| 30 | 30 | import { getUserByName, type User } from "../src/core/users.ts"; |
| 31 | 31 | import { serve, type Servers } from "../src/http/server.ts"; |
| 32 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 32 | import { quietLogs } from "./helpers/globals.ts"; | |
| 33 | 33 | |
| 34 | setLogLevel("error"); | |
| 34 | quietLogs(); | |
| 35 | 35 | |
| 36 | 36 | const PASSWORD = "a-long-apps-passphrase"; |
| 37 | 37 | const DOMAIN = "apps.test"; |
test/web-browse.test.ts +2 −2
| @@ -29,10 +29,10 @@ import { getUserByName } from "../src/core/users.ts"; | ||
| 29 | 29 | import { settled } from "../src/origins/jobs.ts"; |
| 30 | 30 | import { forgetProviders } from "../src/origins/providers/index.ts"; |
| 31 | 31 | import { clearViewCache } from "../src/view/cache.ts"; |
| 32 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 32 | import { quietLogs } from "./helpers/globals.ts"; | |
| 33 | 33 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; |
| 34 | 34 | |
| 35 | setLogLevel("error"); | |
| 35 | quietLogs(); | |
| 36 | 36 | |
| 37 | 37 | const ORIGIN_LOGIN = "octo"; |
| 38 | 38 | const ORIGIN_TOKEN = "octo-origin-secret-7c31"; |
test/web-code.test.ts +2 −2
| @@ -32,7 +32,7 @@ import { signUp } from "../src/actions/users.ts"; | ||
| 32 | 32 | import { createSession } from "../src/core/sessions.ts"; |
| 33 | 33 | import { getRepo, updateRepoSettings } from "../src/core/repos.ts"; |
| 34 | 34 | import { serve, type Servers } from "../src/http/server.ts"; |
| 35 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 35 | import { quietLogs } from "./helpers/globals.ts"; | |
| 36 | 36 | import { |
| 37 | 37 | codeJs, |
| 38 | 38 | editorJs, |
| @@ -44,7 +44,7 @@ import { liveClientJs } from "../src/web/live.ts"; | ||
| 44 | 44 | import { spineOf } from "../src/web/pages/explorer.ts"; |
| 45 | 45 | import { markMatch } from "../src/web/pages/search.ts"; |
| 46 | 46 | |
| 47 | setLogLevel("error"); | |
| 47 | quietLogs(); | |
| 48 | 48 | |
| 49 | 49 | const PASSWORD = "a-long-web-code-passphrase"; |
| 50 | 50 |
test/web-editor.test.ts +2 −2
| @@ -31,7 +31,7 @@ import { listRefs } from "../src/git/read.ts"; | ||
| 31 | 31 | import { resolveOid } from "../src/git/merge.ts"; |
| 32 | 32 | import { repoDir } from "../src/git/repos.ts"; |
| 33 | 33 | import { serve, type Servers } from "../src/http/server.ts"; |
| 34 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 34 | import { quietLogs } from "./helpers/globals.ts"; | |
| 35 | 35 | import { localSource } from "../src/view/local.ts"; |
| 36 | 36 | import { |
| 37 | 37 | MAX_MIRROR_LINES, |
| @@ -41,7 +41,7 @@ import { | ||
| 41 | 41 | import { highlightAsset } from "../src/web/asset.ts"; |
| 42 | 42 | import { highlightToHtml } from "../src/web/highlight-html.ts"; |
| 43 | 43 | |
| 44 | setLogLevel("error"); | |
| 44 | quietLogs(); | |
| 45 | 45 | |
| 46 | 46 | const PASSWORD = "a-long-web-editor-passphrase"; |
| 47 | 47 | const LAMP = "export const lamp = 'lit'\n"; |
test/web-origins.test.ts +5 −7
| @@ -24,7 +24,7 @@ import { getRepo } from "../src/core/repos.ts"; | ||
| 24 | 24 | import { getUserByName } from "../src/core/users.ts"; |
| 25 | 25 | import { forgetProviders } from "../src/origins/providers/index.ts"; |
| 26 | 26 | import { advance, createJob, settled } from "../src/origins/jobs.ts"; |
| 27 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 27 | import { quietLogs, withoutEnv } from "./helpers/globals.ts"; | |
| 28 | 28 | import { |
| 29 | 29 | newFakeOAuth, |
| 30 | 30 | newFakeState, |
| @@ -33,7 +33,7 @@ import { | ||
| 33 | 33 | type FakeOAuth, |
| 34 | 34 | } from "./fake-origin.ts"; |
| 35 | 35 | |
| 36 | setLogLevel("error"); | |
| 36 | quietLogs(); | |
| 37 | 37 | |
| 38 | 38 | const ORIGIN_LOGIN = "octo"; |
| 39 | 39 | const ORIGIN_TOKEN = "octo-origin-secret-9f2a"; |
| @@ -44,6 +44,9 @@ const basic = (user: string, secret: string): string => | ||
| 44 | 44 | `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`; |
| 45 | 45 | |
| 46 | 46 | describe("origins on the web", () => { |
| 47 | // Whatever the environment thinks: this suite decides when GitHub is | |
| 48 | // configured, and its first assertion is about it *not* being. | |
| 49 | withoutEnv("FORGE_GITHUB_CLIENT_ID", "FORGE_URL"); | |
| 47 | 50 | let dataDir: string; |
| 48 | 51 | let originDataDir: string; |
| 49 | 52 | let app: App; |
| @@ -164,10 +167,6 @@ describe("origins on the web", () => { | ||
| 164 | 167 | app = createApp({ dataDir, port: 0 }); |
| 165 | 168 | servers = await serve(app); |
| 166 | 169 | base = servers.url; |
| 167 | // Whatever the environment thinks: this suite decides when GitHub is | |
| 168 | // configured, and its first assertion is about it *not* being. | |
| 169 | delete process.env.FORGE_GITHUB_CLIENT_ID; | |
| 170 | delete process.env.FORGE_URL; | |
| 171 | 170 | alice = await signUp("alice"); |
| 172 | 171 | bob = await signUp("bob"); |
| 173 | 172 | resetConnectLimit(); |
| @@ -175,7 +174,6 @@ describe("origins on the web", () => { | ||
| 175 | 174 | }); |
| 176 | 175 | |
| 177 | 176 | afterAll(async () => { |
| 178 | delete process.env.FORGE_GITHUB_CLIENT_ID; | |
| 179 | 177 | forgejo.stop(); |
| 180 | 178 | github.stop(); |
| 181 | 179 | servers.stop(); |
test/web-phone.test.ts +2 −2
| @@ -14,10 +14,10 @@ import { join } from "node:path"; | ||
| 14 | 14 | import { createApp, type App } from "../src/app.ts"; |
| 15 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | 16 | import { findChromium } from "../src/looks/capture.ts"; |
| 17 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | 18 | import { measurePageWidth } from "./helpers/chrome.ts"; |
| 19 | 19 | |
| 20 | setLogLevel("error"); | |
| 20 | quietLogs(); | |
| 21 | 21 | |
| 22 | 22 | const chromium = findChromium(); |
| 23 | 23 | if (chromium === undefined) { |
test/web-workflows.test.ts +2 −2
| @@ -39,10 +39,10 @@ import { getUserByName } from "../src/core/users.ts"; | ||
| 39 | 39 | import { settled } from "../src/origins/jobs.ts"; |
| 40 | 40 | import { forgetProviders } from "../src/origins/providers/index.ts"; |
| 41 | 41 | import { clearViewCache } from "../src/view/cache.ts"; |
| 42 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 42 | import { quietLogs } from "./helpers/globals.ts"; | |
| 43 | 43 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; |
| 44 | 44 | |
| 45 | setLogLevel("error"); | |
| 45 | quietLogs(); | |
| 46 | 46 | |
| 47 | 47 | const PASSWORD = "a-long-workflow-passphrase"; |
| 48 | 48 |
new above
offline