|
| 1 | +import { describe, expect, it } from 'vitest'; |
| 2 | +import { spawnSync } from 'node:child_process'; |
| 3 | +import fs from 'node:fs'; |
| 4 | +import os from 'node:os'; |
| 5 | +import path from 'node:path'; |
| 6 | +import { fileURLToPath } from 'node:url'; |
| 7 | + |
| 8 | +/** |
| 9 | + * objectui#5304 — `Build & E2E` hung inside `apt-get update` and was cancelled |
| 10 | + * at the 30-minute job ceiling, three times, with a byte-identical last line: |
| 11 | + * |
| 12 | + * Get:5 https://archive.ubuntu.com/ubuntu noble-security InRelease [126 kB] |
| 13 | + * |
| 14 | + * PR #5290 job 95986117935 07:05:48 -> cancelled 07:34:39 (30m14s) |
| 15 | + * PR #5290 job 95993643751 07:38:31 -> cancelled 08:07:32 (30m18s) (re-run) |
| 16 | + * PR #5294 job 95988703670 07:17:27 -> cancelled 07:46:21 (30m17s) |
| 17 | + * |
| 18 | + * The apt call was not in the workflow — it is what `playwright install-deps` |
| 19 | + * shells out to. Nothing bounded it, so a mirror that accepts the connection |
| 20 | + * and then never finishes the transfer consumed the entire job and left the |
| 21 | + * check `cancelled`: a gate that reports NOTHING, which is the worst of the |
| 22 | + * three possible outcomes. |
| 23 | + * |
| 24 | + * `scripts/ensure-chromium-ready.sh` replaces the unconditional apt call with |
| 25 | + * "prove the browser launches; reach for apt only if it does not, and bound it |
| 26 | + * when you do". The four cases below are the whole contract, and the third is |
| 27 | + * the direct regression pin: with apt stubbed to hang forever, the script must |
| 28 | + * still terminate. |
| 29 | + * |
| 30 | + * These run without a browser and without a network — both the launch probe and |
| 31 | + * the dependency install are substituted through the documented env seams. That |
| 32 | + * is deliberate: GitHub Actions cannot be run locally, so the logic that has to |
| 33 | + * survive a stalled mirror is pinned here, where it CAN be measured, rather |
| 34 | + * than only in a workflow nobody can execute outside CI. |
| 35 | + */ |
| 36 | +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); |
| 37 | +const script = path.join(repoRoot, 'scripts/ensure-chromium-ready.sh'); |
| 38 | + |
| 39 | +interface RunResult { |
| 40 | + status: number | null; |
| 41 | + stdout: string; |
| 42 | + stderr: string; |
| 43 | + elapsedMs: number; |
| 44 | +} |
| 45 | + |
| 46 | +function run(env: Record<string, string>): RunResult { |
| 47 | + const startedAt = Date.now(); |
| 48 | + const result = spawnSync('bash', [script], { |
| 49 | + cwd: repoRoot, |
| 50 | + encoding: 'utf8', |
| 51 | + env: { ...process.env, ...env }, |
| 52 | + }); |
| 53 | + return { |
| 54 | + status: result.status, |
| 55 | + stdout: result.stdout ?? '', |
| 56 | + stderr: result.stderr ?? '', |
| 57 | + elapsedMs: Date.now() - startedAt, |
| 58 | + }; |
| 59 | +} |
| 60 | + |
| 61 | +/** A fresh marker path that no test shares with another. */ |
| 62 | +function marker(name: string): string { |
| 63 | + return path.join(fs.mkdtempSync(path.join(os.tmpdir(), `os-5304-${name}-`)), 'deps-ran'); |
| 64 | +} |
| 65 | + |
| 66 | +describe('ensure-chromium-ready.sh — apt is off the happy path', () => { |
| 67 | + it('never invokes the dependency install when Chromium already launches', () => { |
| 68 | + const ran = marker('happy'); |
| 69 | + |
| 70 | + const result = run({ |
| 71 | + OS_CHROMIUM_PROBE_CMD: 'true', |
| 72 | + OS_CHROMIUM_DEPS_CMD: `touch ${ran}`, |
| 73 | + }); |
| 74 | + |
| 75 | + expect(result.status, result.stderr).toBe(0); |
| 76 | + expect( |
| 77 | + fs.existsSync(ran), |
| 78 | + 'The dependency install ran even though Chromium launched. That is the objectui#5304 ' + |
| 79 | + 'exposure back again: an unconditional apt-get on the critical path of the gate.', |
| 80 | + ).toBe(false); |
| 81 | + expect(result.stdout).toMatch(/No apt-get on this path/); |
| 82 | + }); |
| 83 | +}); |
| 84 | + |
| 85 | +describe('ensure-chromium-ready.sh — recovery when a library really is missing', () => { |
| 86 | + it('installs the system libraries and succeeds once Chromium launches', () => { |
| 87 | + const ran = marker('recover'); |
| 88 | + |
| 89 | + // The probe fails until the install has run, then succeeds — the shape of a |
| 90 | + // runner image that genuinely lost a shared library. |
| 91 | + const result = run({ |
| 92 | + OS_CHROMIUM_PROBE_CMD: `test -f ${ran}`, |
| 93 | + OS_CHROMIUM_DEPS_CMD: `touch ${ran}`, |
| 94 | + }); |
| 95 | + |
| 96 | + expect(result.status, result.stderr).toBe(0); |
| 97 | + expect(fs.existsSync(ran)).toBe(true); |
| 98 | + expect(result.stdout).toMatch(/Chromium launches after installing its system libraries/); |
| 99 | + }); |
| 100 | +}); |
| 101 | + |
| 102 | +describe('ensure-chromium-ready.sh — a hanging apt cannot hang the job (the #5304 pin)', () => { |
| 103 | + it('terminates well inside the job ceiling when the dependency install never returns', () => { |
| 104 | + const result = run({ |
| 105 | + OS_CHROMIUM_PROBE_CMD: 'false', |
| 106 | + // Stands in for `apt-get update` against the stalled mirror: it accepts |
| 107 | + // the call and never comes back. |
| 108 | + OS_CHROMIUM_DEPS_CMD: 'sleep 600', |
| 109 | + OS_CHROMIUM_DEPS_TIMEOUT: '2', |
| 110 | + }); |
| 111 | + |
| 112 | + // The defect was unbounded waiting, so the assertion is on the clock. 600s |
| 113 | + // is what the stub would take unbounded; the real incident took 1_800s. |
| 114 | + expect( |
| 115 | + result.elapsedMs, |
| 116 | + `The script ran for ${result.elapsedMs}ms against a dependency install that never ` + |
| 117 | + 'returns. Unbounded again — this is exactly what cancelled three jobs in objectui#5304.', |
| 118 | + ).toBeLessThan(60_000); |
| 119 | + expect(result.status, 'a browser that never became usable must still fail the job').not.toBe(0); |
| 120 | + expect(result.stderr).toMatch(/objectui#5304 signature/); |
| 121 | + }, 120_000); |
| 122 | +}); |
| 123 | + |
| 124 | +describe('ensure-chromium-ready.sh — the gate still reports', () => { |
| 125 | + it('fails the job when Chromium cannot launch even after the install', () => { |
| 126 | + const result = run({ |
| 127 | + OS_CHROMIUM_PROBE_CMD: 'false', |
| 128 | + OS_CHROMIUM_DEPS_CMD: 'true', |
| 129 | + }); |
| 130 | + |
| 131 | + expect( |
| 132 | + result.status, |
| 133 | + 'Exiting 0 with no usable browser would let the E2E suite report on a browser it never ' + |
| 134 | + 'had. The point of #5304 is to RESTORE the signal, so this path must be red.', |
| 135 | + ).toBe(1); |
| 136 | + expect(result.stderr).toMatch(/must not report green/); |
| 137 | + }); |
| 138 | +}); |
| 139 | + |
| 140 | +describe('the workflows actually use it', () => { |
| 141 | + const workflows = ['.github/workflows/ci.yml', '.github/workflows/live-e2e.yml']; |
| 142 | + |
| 143 | + /** |
| 144 | + * Whole-line comments removed. Required, not cosmetic: both workflows now |
| 145 | + * explain the incident in prose and name the very spellings asserted against |
| 146 | + * below, so a scan that counted the prose would report an unbounded apt call |
| 147 | + * that no step makes. |
| 148 | + */ |
| 149 | + const withoutComments = (yaml: string): string => |
| 150 | + yaml |
| 151 | + .split('\n') |
| 152 | + .filter((line) => !/^\s*#/.test(line)) |
| 153 | + .join('\n'); |
| 154 | + |
| 155 | + it.each(workflows)('%s calls the script instead of shelling straight to apt', (file) => { |
| 156 | + const yaml = withoutComments(fs.readFileSync(path.join(repoRoot, file), 'utf8')); |
| 157 | + |
| 158 | + expect( |
| 159 | + yaml, |
| 160 | + `${file} must drive Chromium provisioning through scripts/ensure-chromium-ready.sh — ` + |
| 161 | + 'that is the only place the apt call is bounded.', |
| 162 | + ).toMatch(/ensure-chromium-ready\.sh/); |
| 163 | + |
| 164 | + // The two spellings that reintroduce the unbounded `apt-get update`. |
| 165 | + // `playwright install chromium` (no `--with-deps`) is deliberately still |
| 166 | + // allowed and is what the cache-miss step runs: it downloads browser |
| 167 | + // binaries from Playwright's CDN and touches no apt mirror. |
| 168 | + expect( |
| 169 | + yaml, |
| 170 | + `${file} reintroduces an unbounded apt-get: 'playwright install --with-deps' and ` + |
| 171 | + "'playwright install-deps' both run 'apt-get update' with no timeout, which is the " + |
| 172 | + 'objectui#5304 defect. Let scripts/ensure-chromium-ready.sh decide, and bound, that call.', |
| 173 | + ).not.toMatch(/playwright\s+install(-deps|\s+--with-deps)/); |
| 174 | + }); |
| 175 | +}); |
0 commit comments