diff --git a/setup b/setup index 163865731..1b2842a9b 100755 --- a/setup +++ b/setup @@ -261,6 +261,82 @@ ensure_playwright_browser() { fi } +# Ensure a color-emoji font is installed (Linux only). +# +# Chromium renders emoji code points as .notdef "tofu" (▯) when no color-emoji +# font is installed. macOS ships "Apple Color Emoji" and Windows ships "Segoe UI +# Emoji", so they're fine out of the box. Most Linux distros and containers ship +# NO color-emoji font, which is why make-pdf output shows tofu in headers/tables +# that contain emoji. Install Noto Color Emoji to fix it. +# +# Best-effort: warn (don't fail) if we can't install — PDFs still generate, they +# just fall back to tofu for emoji as before. Skip entirely with +# GSTACK_SKIP_FONTS=1 (CI without sudo, managed machines, offline envs). +# +# Returns 0 and sets EMOJI_FONT_INSTALLED=1 when it actually installs a font. +EMOJI_FONT_INSTALLED=0 +ensure_emoji_font() { + # macOS/Windows ship a color-emoji font; nothing to do. + [ "$(uname -s)" = "Linux" ] || return 0 + [ "${GSTACK_SKIP_FONTS:-0}" = "1" ] && return 0 + + # Idempotency: a real COLOR emoji font that resolves for an actual emoji code + # point (U+1F600). `fc-list :lang=und-zsye` is too broad — it matches symbol + # and last-resort fallback fonts — so we use fc-match and require color=True. + if command -v fc-match >/dev/null 2>&1; then + if fc-match -f '%{family[0]}\t%{color}\n' ':lang=und-zsye:charset=1F600' 2>/dev/null | grep -qi 'True'; then + return 0 + fi + fi + + local sudo="" + if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then + # -n: never prompt. If a password is required we fail fast into the + # warn-not-fail path below instead of hanging a non-interactive setup. + sudo="sudo -n" + fi + + if command -v apt-get >/dev/null 2>&1; then + echo "Installing color-emoji font (fonts-noto-color-emoji) so make-pdf emoji render (set GSTACK_SKIP_FONTS=1 to skip)..." + DEBIAN_FRONTEND=noninteractive timeout 30 $sudo apt-get update -qq >/dev/null 2>&1 || true + DEBIAN_FRONTEND=noninteractive $sudo apt-get install -y -qq fonts-noto-color-emoji >/dev/null 2>&1 || return 1 + elif command -v dnf >/dev/null 2>&1; then + echo "Installing color-emoji font (google-noto-color-emoji-fonts)..." + $sudo dnf install -y google-noto-color-emoji-fonts >/dev/null 2>&1 || return 1 + elif command -v pacman >/dev/null 2>&1; then + echo "Installing color-emoji font (noto-fonts-emoji)..." + $sudo pacman -Sy --noconfirm noto-fonts-emoji >/dev/null 2>&1 || return 1 + elif command -v apk >/dev/null 2>&1; then + echo "Installing color-emoji font (font-noto-emoji)..." + $sudo apk add --no-cache font-noto-emoji >/dev/null 2>&1 || return 1 + else + return 1 + fi + + # Refresh fontconfig cache so Chromium picks up the new font. Run under sudo + # for the system cache dirs (unprivileged fc-cache fails on unwritable dirs). + if command -v fc-cache >/dev/null 2>&1; then + $sudo fc-cache -f >/dev/null 2>&1 || fc-cache -f >/dev/null 2>&1 || true + fi + EMOJI_FONT_INSTALLED=1 + return 0 +} + +# After a fresh font install, stop any running browse render daemon so the next +# make-pdf render spawns a fresh Chromium that sees the new font. Chromium +# caches its font list at process start, so a daemon that was alive before the +# install would keep emitting tofu. `browse stop` is the graceful API; the +# daemon auto-respawns on the next render. Best-effort and per-project-root, so +# we also print a note for daemons in other roots. +refresh_browse_daemon_for_fonts() { + [ "$EMOJI_FONT_INSTALLED" -eq 1 ] || return 0 + if [ -x "$BROWSE_BIN" ]; then + "$BROWSE_BIN" stop >/dev/null 2>&1 || true + fi + echo " Installed a color-emoji font. The next make-pdf render will show emoji." + echo " If a gstack browser is running in another project, restart it to pick up the font." +} + prepare_bun_for_windows_compile() { BUN_CMD="bun" BUN_CMD_WAS_COPIED=0 @@ -433,6 +509,19 @@ if ! ensure_playwright_browser; then exit 1 fi +# 2b. Ensure a color-emoji font is installed so make-pdf emoji render (Linux). +# Best-effort: warn instead of failing if it can't install. +if ! ensure_emoji_font; then + echo " Note: could not auto-install a color-emoji font. Emoji in make-pdf" >&2 + echo " output may render as boxes (▯). Install one manually, e.g.:" >&2 + echo " Debian/Ubuntu: sudo apt-get install fonts-noto-color-emoji" >&2 + echo " Fedora: sudo dnf install google-noto-color-emoji-fonts" >&2 + echo " Arch: sudo pacman -S noto-fonts-emoji" >&2 + echo " Alpine: sudo apk add font-noto-emoji" >&2 +else + refresh_browse_daemon_for_fonts +fi + # 3. Ensure ~/.gstack global state directory exists mkdir -p "$HOME/.gstack/projects" diff --git a/test/setup-emoji-font.test.ts b/test/setup-emoji-font.test.ts new file mode 100644 index 000000000..83475387d --- /dev/null +++ b/test/setup-emoji-font.test.ts @@ -0,0 +1,166 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP_SCRIPT = path.join(ROOT, 'setup'); +const SETUP_SRC = fs.readFileSync(SETUP_SCRIPT, 'utf-8'); + +// Slice out the ensure_emoji_font helper body via anchors so the test is +// resilient to line-number drift (same pattern as setup-windows-fallback). +function extractHelper(): string { + const start = SETUP_SRC.indexOf('ensure_emoji_font() {'); + const end = SETUP_SRC.indexOf('\n}\n', start); + if (start < 0 || end < 0) throw new Error('Could not locate ensure_emoji_font() in setup'); + return SETUP_SRC.slice(start, end + 2); +} + +describe('setup: ensure_emoji_font static invariants', () => { + const helper = extractHelper(); + + test('helper is defined and Linux-guarded', () => { + expect(SETUP_SRC).toContain('ensure_emoji_font() {'); + expect(helper).toContain('[ "$(uname -s)" = "Linux" ] || return 0'); + }); + + test('honors the GSTACK_SKIP_FONTS escape hatch', () => { + expect(helper).toContain('GSTACK_SKIP_FONTS'); + }); + + test('detects an installed COLOR emoji font via fc-match (not the broad fc-list query)', () => { + expect(helper).toContain('fc-match'); + expect(helper).toContain(':lang=und-zsye:charset=1F600'); + // Must gate on color=True so symbol / last-resort fallback fonts don't + // false-positive and skip a needed install. + expect(helper).toMatch(/grep -qi ['"]True['"]/); + // The broad fc-list query that matched LastResort is NOT used for detection. + // (Check executable lines only — the docblock may mention fc-list to explain + // why we avoid it.) + const codeLines = helper + .split('\n') + .filter((l) => !l.trim().startsWith('#')) + .join('\n'); + expect(codeLines).not.toContain('fc-list'); + }); + + test('uses non-interactive sudo so a password prompt fails fast (no hang)', () => { + expect(helper).toContain('sudo -n'); + }); + + test('install path is non-interactive and timeout-guarded', () => { + expect(helper).toContain('DEBIAN_FRONTEND=noninteractive'); + expect(helper).toMatch(/timeout 30 .*apt-get update/); + }); + + test('covers all four package managers with the correct package names', () => { + expect(helper).toContain('apt-get install -y -qq fonts-noto-color-emoji'); + expect(helper).toContain('dnf install -y google-noto-color-emoji-fonts'); + expect(helper).toContain('pacman -Sy --noconfirm noto-fonts-emoji'); + expect(helper).toContain('apk add --no-cache font-noto-emoji'); + }); + + test('refreshes the fontconfig cache under sudo after install', () => { + expect(helper).toMatch(/\$sudo fc-cache -f/); + }); + + test('marks EMOJI_FONT_INSTALLED on success and warns (not fails) elsewhere', () => { + expect(helper).toContain('EMOJI_FONT_INSTALLED=1'); + // Failure branches return 1 (caller warns) rather than `exit`. + expect(helper).not.toContain('exit 1'); + }); + + test('refresh_browse_daemon_for_fonts stops the daemon gracefully (no broad pkill)', () => { + const dStart = SETUP_SRC.indexOf('refresh_browse_daemon_for_fonts() {'); + const dEnd = SETUP_SRC.indexOf('\n}\n', dStart); + expect(dStart).toBeGreaterThanOrEqual(0); + const body = SETUP_SRC.slice(dStart, dEnd); + expect(body).toContain('"$BROWSE_BIN" stop'); + expect(body).not.toMatch(/pkill/); + }); + + test('the call site warns-not-fails and never aborts setup', () => { + expect(SETUP_SRC).toContain('if ! ensure_emoji_font; then'); + expect(SETUP_SRC).toContain('refresh_browse_daemon_for_fonts'); + }); +}); + +// Behavior matrix: source the extracted helper into a temp shell with a faked +// PATH so we exercise the real control flow without touching the host system. +// We fake `uname` to report Linux so the guard doesn't short-circuit on the +// macOS/Linux test runner, and fake the package managers with sentinel-touching +// stubs so we can assert whether an install was attempted. +describe.skipIf(process.platform === 'win32')('setup: ensure_emoji_font behavior', () => { + function runHelper(fcMatchOutput: string): { + exit: number; + installInstalled: string; + aptCalled: boolean; + fcCacheCalled: boolean; + stderr: string; + } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-emoji-')); + try { + const bin = path.join(tmp, 'bin'); + fs.mkdirSync(bin); + const sentinelApt = path.join(tmp, 'apt-called'); + const sentinelCache = path.join(tmp, 'fc-cache-called'); + + const stub = (name: string, body: string) => { + const p = path.join(bin, name); + fs.writeFileSync(p, `#!/usr/bin/env bash\n${body}\n`); + fs.chmodSync(p, 0o755); + }; + stub('uname', 'echo Linux'); + // fc-match prints whatever the case wants; supports the -f format arg. + stub('fc-match', `printf '%s\\n' ${JSON.stringify(fcMatchOutput)}`); + stub('apt-get', `touch ${JSON.stringify(sentinelApt)}; exit 0`); + stub('fc-cache', `touch ${JSON.stringify(sentinelCache)}; exit 0`); + stub('sudo', 'shift; "$@"'); // sudo -n → run directly + stub('command', ''); // never used; `command -v` is a builtin + stub('timeout', 'shift; "$@"'); // timeout 30 → run + stub('id', 'echo 1000'); // non-root so the sudo branch is taken + + const helper = extractHelper(); + const script = [ + 'set -e', + 'EMOJI_FONT_INSTALLED=0', + helper, + 'ensure_emoji_font; rc=$?', + 'echo "EXIT=$rc"', + 'echo "INSTALLED=$EMOJI_FONT_INSTALLED"', + ].join('\n'); + + const result = spawnSync('bash', ['-c', script], { + encoding: 'utf-8', + timeout: 10000, + env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, + }); + const out = result.stdout ?? ''; + return { + exit: Number((out.match(/EXIT=(\d+)/) ?? [])[1] ?? -1), + installInstalled: (out.match(/INSTALLED=(\d+)/) ?? [])[1] ?? '?', + aptCalled: fs.existsSync(sentinelApt), + fcCacheCalled: fs.existsSync(sentinelCache), + stderr: result.stderr ?? '', + }; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + } + + test('short-circuits when a color emoji font already resolves (no install)', () => { + const r = runHelper('Noto Color Emoji\tTrue'); + expect(r.exit).toBe(0); + expect(r.aptCalled).toBe(false); + expect(r.installInstalled).toBe('0'); + }); + + test('installs when only a non-color fallback resolves (color=False)', () => { + const r = runHelper('LastResort\tFalse'); + expect(r.exit).toBe(0); + expect(r.aptCalled).toBe(true); + expect(r.fcCacheCalled).toBe(true); + expect(r.installInstalled).toBe('1'); + }); +});