Public: anyone can read this; the crew can change it.
The loopback-only looks test runs in the forge's check and says what Chrome does there #10633
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10631-the-loopback-only-looks-test-runs-in-the → main 1 commit fork
2 files
changed +104
−0
test/look-loopback-only.child.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** | |
| 2 | * Runs inside `unshare -rn` with only `lo` up (see look-loopback-only.test.ts): | |
| 3 | * serves a page on 127.0.0.1, takes a look of it with the real `takeLook`, and | |
| 4 | * prints exactly one line — `loopback-only look: png <bytes>` or | |
| 5 | * `loopback-only look: error <message>`. | |
| 6 | */ | |
| 7 | import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { takeLook } from "../src/looks/capture.ts"; | |
| 11 | ||
| 12 | const server = Bun.serve({ | |
| 13 | hostname: "127.0.0.1", | |
| 14 | port: 0, | |
| 15 | fetch: () => | |
| 16 | new Response("<!doctype html><title>loopback</title><h1>loopback only</h1>", { | |
| 17 | headers: { "content-type": "text/html; charset=utf-8" }, | |
| 18 | }), | |
| 19 | }); | |
| 20 | const dir = mkdtempSync(join(tmpdir(), "look-lo-")); | |
| 21 | const out = join(dir, "look.png"); | |
| 22 | let line: string; | |
| 23 | try { | |
| 24 | const got = await takeLook({ | |
| 25 | url: `http://127.0.0.1:${server.port}/`, | |
| 26 | out, | |
| 27 | width: 800, | |
| 28 | height: 600, | |
| 29 | timeoutMs: 20_000, | |
| 30 | }); | |
| 31 | line = got.ok | |
| 32 | ? existsSync(out) | |
| 33 | ? `png ${statSync(out).size}` | |
| 34 | : "error no picture written" | |
| 35 | : `error ${got.error}`; | |
| 36 | } catch (e) { | |
| 37 | line = `error ${e instanceof Error ? e.message : String(e)}`; | |
| 38 | } finally { | |
| 39 | server.stop(true); | |
| 40 | rmSync(dir, { recursive: true, force: true }); | |
| 41 | } | |
| 42 | console.log(`loopback-only look: ${line.replace(/\s+/g, " ").trim()}`); | |
| 43 | process.exit(0); |
test/look-loopback-only.test.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * What Chrome does when the only interface is loopback (#10631, first half of | |
| 3 | * #10619). `takeLook` runs inside `unshare -rn` with just `lo` up, against a | |
| 4 | * Bun server on 127.0.0.1. The test prints what happened and asserts only that | |
| 5 | * one of the two outcomes came back inside the budget — it proves the failure | |
| 6 | * (or its absence), it does not fix it. Skipped, by name and with the reason | |
| 7 | * printed, where unshare, ip, Chromium or namespace permission is missing. | |
| 8 | */ | |
| 9 | import { describe, expect, test } from "bun:test"; | |
| 10 | import { findChromium } from "../src/looks/capture.ts"; | |
| 11 | ||
| 12 | const BUDGET_MS = 30_000; | |
| 13 | const CHILD = new URL("./look-loopback-only.child.ts", import.meta.url).pathname; | |
| 14 | ||
| 15 | function missing(): string | undefined { | |
| 16 | if (process.platform !== "linux") return "not linux"; | |
| 17 | if (Bun.which("unshare") === null) return "unshare is not installed"; | |
| 18 | if (Bun.which("ip") === null) return "ip is not installed"; | |
| 19 | if (findChromium() === undefined) return "no Chromium found"; | |
| 20 | const probe = Bun.spawnSync(["unshare", "-rn", "ip", "link", "set", "lo", "up"], { | |
| 21 | stdout: "ignore", | |
| 22 | stderr: "pipe", | |
| 23 | }); | |
| 24 | if (probe.exitCode !== 0) | |
| 25 | return `cannot make a network namespace: ${probe.stderr.toString().trim().slice(0, 200)}`; | |
| 26 | return undefined; | |
| 27 | } | |
| 28 | ||
| 29 | const why = missing(); | |
| 30 | if (why !== undefined) console.log(`loopback-only look: skipped, ${why}`); | |
| 31 | ||
| 32 | describe("takeLook with only loopback up", () => { | |
| 33 | (why === undefined ? test : test.skip)( | |
| 34 | why === undefined ? "answers with a picture or an error" : `skipped: ${why}`, | |
| 35 | async () => { | |
| 36 | const proc = Bun.spawn( | |
| 37 | [ | |
| 38 | "unshare", "-rn", "sh", "-c", | |
| 39 | `ip link set lo up && exec "${process.execPath}" "${CHILD}"`, | |
| 40 | ], | |
| 41 | { stdout: "pipe", stderr: "pipe", env: process.env }, | |
| 42 | ); | |
| 43 | let timedOut = false; | |
| 44 | const timer = setTimeout(() => { | |
| 45 | timedOut = true; | |
| 46 | proc.kill("SIGKILL"); | |
| 47 | }, BUDGET_MS); | |
| 48 | const [out, err] = await Promise.all([ | |
| 49 | new Response(proc.stdout).text(), | |
| 50 | new Response(proc.stderr).text(), | |
| 51 | proc.exited, | |
| 52 | ]); | |
| 53 | clearTimeout(timer); | |
| 54 | if (timedOut) throw new Error(`loopback-only look: no answer within ${BUDGET_MS / 1000} s`); | |
| 55 | const line = out.split("\n").find((l) => l.startsWith("loopback-only look: ")); | |
| 56 | console.log(line ?? `loopback-only look: error child printed nothing (${err.trim().slice(0, 300)})`); | |
| 57 | expect(line).toMatch(/^loopback-only look: (png \d+|error .+)$/); | |
| 58 | }, | |
| 59 | BUDGET_MS + 5_000, | |
| 60 | ); | |
| 61 | }); |