mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-01 15:51:41 +02:00
62024d114c
* fix(make-pdf): emoji font fallback in print CSS Emoji code points rendered as .notdef tofu (▯) because the body and @top-center font stacks had no emoji family for Chromium to fall back to. Add SANS_STACK / CJK_STACK / EMOJI_FAMILIES constants (one source of truth per family list) and append the emoji families before the generic sans-serif in the two stacks that can hold emoji. The @bottom-* boxes hold counters / a fixed CONFIDENTIAL string, so they share SANS_STACK without emoji. Non-emoji output is byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(setup): auto-install color-emoji font on Linux macOS and Windows ship a color-emoji font; most Linux distros/containers ship none, so make-pdf emits tofu there. ensure_emoji_font() best-effort installs fonts-noto-color-emoji (apt, with dnf/pacman/apk fallbacks) and refreshes the fontconfig cache. Hardened: Linux-only guard, GSTACK_SKIP_FONTS escape hatch, fc-match color=True detection (the broad fc-list query false-matched LastResort), sudo -n so a password prompt fails fast instead of hanging, DEBIAN_FRONTEND=noninteractive, timeout 30 on apt update, and fc-cache under sudo. Warns instead of failing. After a fresh install, refresh_browse_daemon_for_fonts() runs 'browse stop' so the next render spawns a Chromium that sees the new font (font fallback is process-cached). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(make-pdf): emoji render gate (pdffonts + pixel proof) pdftotext is a false oracle for emoji: Skia preserves the Unicode in the text cluster even when the glyph drew as .notdef tofu, so extraction passes on a broken render. The gate instead asserts (1) pdffonts shows an emoji family embedded and (2) pdftoppm rasterizes the page to color (measured ~1650 saturated pixels vs ~0 for tofu). pdfimages is not used: macOS embeds color emoji as Type 3 fonts, so it lists nothing even on a correct render. Adds resolvePopplerTool() (DRY resolver, returns null for clean skips) and a fixture exercising FE0F variation-selector emoji. Skips cleanly when poppler tools or a color-emoji font are unavailable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(make-pdf): install emoji font + run emoji gate on Ubuntu Install fonts-noto-color-emoji before Chromium launches on the Ubuntu leg (macOS already ships Apple Color Emoji), refresh fontconfig, and log the fc-match result. Run the whole make-pdf/test/e2e/ dir so the emoji gate runs alongside the combined-features copy-paste gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * harden(make-pdf): emoji gate + font install per adversarial review Codex adversarial pass on the implementation diff flagged five robustness gaps, all fixed here: - emoji-gate skipped green in CI when poppler/font prerequisites were absent, which could let the tofu regression ship behind a green build. Missing prerequisites are now a HARD FAILURE when process.env.CI is set; local dev still skips cleanly. - execFileSync children (make-pdf, pdffonts, pdftoppm, fc-match) had no timeout; a wedged binary or hostile GSTACK_*_BIN override could hang the job past Bun's test timeout. Each child now has a 25s ceiling. - PPM parser trusted header tokens blindly; malformed/variant output gave a silently-wrong count. Now validates magic/dimensions/maxval and pixel-buffer length, handles header comments, throws a hard diagnostic on mismatch. - predictable /tmp paths were collision/symlink-prone; now mkdtempSync under /tmp (kept under /tmp for browse's validateOutputPath allowlist). - only apt-get update was timeout-wrapped; dnf/pacman/apk installs and apt install can hang on locks/mirrors. All package installs now timeout-bound. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v1.52.2.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(make-pdf): document color-emoji font requirement + GSTACK_SKIP_FONTS Extend the Linux font note to cover the color-emoji font that make-pdf emoji rendering needs: setup auto-installs fonts-noto-color-emoji, the print CSS falls back through Apple/Segoe/Noto emoji families, and GSTACK_SKIP_FONTS=1 opts out. Edit the .tmpl and regenerate SKILL.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
173 lines
7.0 KiB
TypeScript
173 lines
7.0 KiB
TypeScript
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/);
|
|
// Every package-manager INSTALL (not just apt update) must be timeout-bound
|
|
// so a stuck lock/mirror fails fast instead of hanging setup.
|
|
expect(helper).toMatch(/timeout \d+ .*apt-get install/);
|
|
expect(helper).toMatch(/timeout \d+ .*dnf install/);
|
|
expect(helper).toMatch(/timeout \d+ .*pacman -Sy/);
|
|
expect(helper).toMatch(/timeout \d+ .*apk add/);
|
|
});
|
|
|
|
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 <cmd> → run <cmd> directly
|
|
stub('command', ''); // never used; `command -v` is a builtin
|
|
stub('timeout', 'shift; "$@"'); // timeout 30 <cmd> → run <cmd>
|
|
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');
|
|
});
|
|
});
|