From fb20ada4ace0742416af3f97efcadf2902e16462 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 14 Aug 2026 17:11:48 -0700 Subject: [PATCH] fix(migrations): v1.65 Chromium re-fetch actually re-downloads, and success is verified before .done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration (renamed from the provisional v1.64.0.0 slot, which open PR #2564 claims) deleted only the poisoned .app while Playwright's INSTALLATION_COMPLETE marker survived in the revision dir — so the advertised 'bunx playwright install chromium' re-fetch no-opped and the user finished the upgrade with no browser and a success message. Now: the whole chromium- dir goes, bunx runs cwd-pinned to the install root, .done is gated on a verified executable, and a needs-refetch sentinel makes re-runs retry a failed download. Stranded rev dirs (markers without .app) also re-trigger. 6 hermetic tests, red-first. --- gstack-upgrade/migrations/v1.64.0.0.sh | 75 -------- test/migrations-v1.65.0.0.test.ts | 233 +++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 75 deletions(-) delete mode 100755 gstack-upgrade/migrations/v1.64.0.0.sh create mode 100644 test/migrations-v1.65.0.0.test.ts diff --git a/gstack-upgrade/migrations/v1.64.0.0.sh b/gstack-upgrade/migrations/v1.64.0.0.sh deleted file mode 100755 index a5aa440c6..000000000 --- a/gstack-upgrade/migrations/v1.64.0.0.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -# Migration: v1.64.0.0 — repair Chrome-for-Testing bundles poisoned by the -# old in-place rebrand (#2242). -# -# Why a migration: pre-v1.64 launchHeaded() rewrote the Chromium .app's -# Info.plist ("Google Chrome for Testing" → "GStack Browser") and overwrote -# its Resources/*.icns — inside the SHARED Playwright cache. That broke the -# codesign seal (GPU process exit_code=5; headed mode dead on macOS 26) and -# poisoned the cache for the user's OTHER Playwright projects too. Deleting -# the rebrand code fixes fresh installs only; every existing macOS install -# still has the mutated bundle on disk. This migration removes poisoned -# bundles and re-fetches a clean one so the upgrade doesn't leave the user -# with zero working browser (the browse launch path also self-heals, as the -# belt to this suspenders, for installs that never run migrations). -# -# Affected: macOS installs that ever ran headed mode before v1.64. -# -# Idempotent: detection is content-based (plist contains "GStack Browser"); -# a clean cache is a no-op, and the .done touchfile gates re-runs. The -# re-fetch is best-effort and non-fatal per the migration contract. - -set -u - -GSTACK_HOME="${GSTACK_HOME:-${HOME}/.gstack}" -MIGRATION_DIR="${GSTACK_HOME}/.migrations" -DONE="${MIGRATION_DIR}/v1.64.0.0.done" -mkdir -p "${MIGRATION_DIR}" 2>/dev/null || true -[ -f "${DONE}" ] && exit 0 - -# macOS only: the mutation targeted .app bundle plists. -if [ "$(uname -s 2>/dev/null)" != "Darwin" ]; then - touch "${DONE}" - exit 0 -fi - -PW_CACHE="${PLAYWRIGHT_BROWSERS_PATH:-${HOME}/Library/Caches/ms-playwright}" -REMOVED=0 - -if [ -d "${PW_CACHE}" ]; then - # Every Chrome-for-Testing bundle in the cache (one per pinned chromium build). - while IFS= read -r plist; do - if grep -q "GStack Browser" "${plist}" 2>/dev/null; then - app_dir="$(dirname "$(dirname "${plist}")")" - case "${app_dir}" in - "${PW_CACHE}"/*.app|"${PW_CACHE}"/*/*.app|"${PW_CACHE}"/*/*/*.app) - echo " [v1.64.0.0] removing rebrand-poisoned bundle: ${app_dir}" >&2 - rm -rf "${app_dir}" - REMOVED=1 - ;; - *) - echo " [v1.64.0.0] WARNING: poisoned plist outside the Playwright cache shape, skipping: ${plist}" >&2 - ;; - esac - fi - done < <(find "${PW_CACHE}" -maxdepth 5 -name "Info.plist" -path "*.app/Contents/Info.plist" 2>/dev/null) -fi - -if [ "${REMOVED}" = "1" ]; then - # Re-fetch immediately: migrations run AFTER ./setup, so without this the - # user finishes the upgrade with no working browser at all (headless AND - # headed use the same bundle). Best-effort — a failed download leaves the - # actionable command printed and the launch-time self-heal message covers - # the rest. - echo " [v1.64.0.0] re-fetching a clean Chromium (bunx playwright install chromium)..." >&2 - if command -v bunx >/dev/null 2>&1 && bunx playwright install chromium >&2; then - echo " [v1.64.0.0] clean Chromium installed." >&2 - else - echo " [v1.64.0.0] WARNING: automatic re-fetch failed. Run manually: bunx playwright install chromium" >&2 - fi -else - echo " [v1.64.0.0] no rebrand-poisoned bundles found — no-op." >&2 -fi - -touch "${DONE}" -exit 0 diff --git a/test/migrations-v1.65.0.0.test.ts b/test/migrations-v1.65.0.0.test.ts new file mode 100644 index 000000000..3be29185e --- /dev/null +++ b/test/migrations-v1.65.0.0.test.ts @@ -0,0 +1,233 @@ +/** + * v1.65.0.0 migration — remove rebrand-poisoned Chromium bundles (#2242) + * and re-fetch a clean one, gating .done on a VERIFIED end state. + * + * Exercises the migration in a hermetic temp HOME + temp Playwright cache + * (HOME / GSTACK_HOME / PLAYWRIGHT_BROWSERS_PATH all redirected) with a + * stubbed bunx on PATH — nothing touches the real cache. Covers: + * - whole-revision-dir removal (INSTALLATION_COMPLETE marker included, so + * `playwright install` can't no-op with "is already downloaded") + * - clean bundles untouched + * - re-fetch runs from the gstack install root (repo-pinned playwright) + * - .done only written once a Chromium executable verifiably exists + * - failed re-fetch → warning + retry on next run (needs-refetch sentinel) + * - stranded revision dir (markers without .app) re-triggers the re-fetch + * - idempotent re-run after success + * - non-Darwin early exit + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const MIGRATION = path.join(ROOT, 'gstack-upgrade', 'migrations', 'v1.65.0.0.sh'); + +let tmpHome: string; +let fakeBinDir: string; +let pwCache: string; + +const POISONED_PLIST = + 'CFBundleNameGStack Browser'; +const CLEAN_PLIST = + 'CFBundleNameGoogle Chrome for Testing'; + +/** Build a Playwright-cache-shaped bundle: /chrome-mac/.app */ +function makeBundle( + revDir: string, + opts: { plist: string; withExecutable?: boolean; withMarkers?: boolean } +): string { + const appDir = path.join(revDir, 'chrome-mac', 'Google Chrome for Testing.app'); + const contents = path.join(appDir, 'Contents'); + fs.mkdirSync(contents, { recursive: true }); + fs.writeFileSync(path.join(contents, 'Info.plist'), opts.plist); + if (opts.withExecutable) { + const macos = path.join(contents, 'MacOS'); + fs.mkdirSync(macos, { recursive: true }); + fs.writeFileSync(path.join(macos, 'Google Chrome for Testing'), '#!/bin/sh\n', { + mode: 0o755, + }); + } + if (opts.withMarkers ?? true) { + fs.writeFileSync(path.join(revDir, 'INSTALLATION_COMPLETE'), ''); + fs.writeFileSync(path.join(revDir, 'DEPENDENCIES_VALIDATED'), ''); + } + return appDir; +} + +/** + * Stub bunx: records every invocation (args + cwd) and, unless told + * otherwise, simulates a successful `playwright install chromium` by + * creating a fresh revision dir with an executable in the temp cache. + */ +function makeFakeBunx(opts: { createsExecutable?: boolean } = {}): string { + const creates = opts.createsExecutable ?? true; + const callLog = path.join(fakeBinDir, 'bunx-calls.log'); + const script = `#!/bin/bash +echo "bunx $@ (pwd=$(pwd))" >> "${callLog}" +${ + creates + ? `FRESH="\${PLAYWRIGHT_BROWSERS_PATH}/chromium-9999/chrome-mac/Chromium.app/Contents/MacOS" +mkdir -p "\${FRESH}" +printf '#!/bin/sh\\n' > "\${FRESH}/Chromium" +chmod 755 "\${FRESH}/Chromium" +touch "\${PLAYWRIGHT_BROWSERS_PATH}/chromium-9999/INSTALLATION_COMPLETE"` + : '# simulates an offline / failed download: no files created' +} +exit 0 +`; + fs.writeFileSync(path.join(fakeBinDir, 'bunx'), script, { mode: 0o755 }); + return callLog; +} + +function run(extraEnv: Record = {}): { + code: number; + stdout: string; + stderr: string; +} { + const r = spawnSync(MIGRATION, [], { + env: { + PATH: `${fakeBinDir}:/usr/bin:/bin`, + HOME: tmpHome, + GSTACK_HOME: path.join(tmpHome, '.gstack'), + PLAYWRIGHT_BROWSERS_PATH: pwCache, + ...extraEnv, + }, + encoding: 'utf-8', + cwd: tmpHome, + }); + return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' }; +} + +const doneFile = () => path.join(tmpHome, '.gstack', '.migrations', 'v1.65.0.0.done'); + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-v1.65-')); + fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-v1.65-fake-')); + pwCache = path.join(tmpHome, 'pw-cache', 'ms-playwright'); + fs.mkdirSync(pwCache, { recursive: true }); + fs.mkdirSync(path.join(tmpHome, '.gstack'), { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(fakeBinDir, { recursive: true, force: true }); +}); + +describe('v1.65.0.0 migration — poisoned bundle removal', () => { + test('poisoned revision dir removed WHOLE (markers included); clean bundle untouched; verified re-fetch → .done', () => { + const poisonedRev = path.join(pwCache, 'chromium-1234'); + makeBundle(poisonedRev, { plist: POISONED_PLIST }); + const cleanRev = path.join(pwCache, 'chromium-5678'); + const cleanApp = makeBundle(cleanRev, { plist: CLEAN_PLIST, withExecutable: true }); + const log = makeFakeBunx(); + + const r = run(); + expect(r.code).toBe(0); + + // The WHOLE revision dir is gone — .app AND the INSTALLATION_COMPLETE / + // DEPENDENCIES_VALIDATED markers. Removing only the .app leaves markers + // that make `playwright install chromium` no-op ("is already + // downloaded"), stranding the user with NO browser + a success message. + expect(fs.existsSync(poisonedRev)).toBe(false); + + // Clean bundle untouched. + expect(fs.existsSync(path.join(cleanApp, 'Contents', 'Info.plist'))).toBe(true); + expect(fs.existsSync(path.join(cleanRev, 'INSTALLATION_COMPLETE'))).toBe(true); + + // Re-fetch invoked... + const calls = fs.readFileSync(log, 'utf-8'); + expect(calls).toContain('playwright install chromium'); + // ...from the gstack install root (repo-pinned playwright version), not + // from the arbitrary cwd the migration runner happened to use. + const realRoot = fs.realpathSync(ROOT); + expect(calls.includes(`pwd=${ROOT}`) || calls.includes(`pwd=${realRoot}`)).toBe(true); + + // Verified end state → .done written. + expect(fs.existsSync(doneFile())).toBe(true); + }); + + test('re-fetch produces no executable → WARNING, .done NOT written; next run retries and completes', () => { + makeBundle(path.join(pwCache, 'chromium-1234'), { plist: POISONED_PLIST }); + const log = makeFakeBunx({ createsExecutable: false }); + + const r = run(); + expect(r.code).toBe(0); // non-fatal per the migration contract + expect(r.stderr).toContain('WARNING'); + expect(r.stderr).toContain('bunx playwright install chromium'); + // Removal succeeded but the end state is "no browser": .done must NOT + // be written so a re-run retries instead of recording success. + expect(fs.existsSync(doneFile())).toBe(false); + + // Re-run with a working fetch: the pending state re-triggers the + // re-fetch even though the scans have nothing left to remove. + fs.rmSync(log, { force: true }); + makeFakeBunx({ createsExecutable: true }); + const r2 = run(); + expect(r2.code).toBe(0); + expect(fs.readFileSync(log, 'utf-8')).toContain('playwright install chromium'); + expect(fs.existsSync(doneFile())).toBe(true); + }); + + test('stranded revision dir (install markers, no .app) re-triggers removal + re-fetch', () => { + // The state an older version of this migration could leave behind: + // .app removed, INSTALLATION_COMPLETE still present → `playwright + // install` no-ops while the user has no browser at all. + const strandedRev = path.join(pwCache, 'chromium-1234'); + fs.mkdirSync(path.join(strandedRev, 'chrome-mac'), { recursive: true }); + fs.writeFileSync(path.join(strandedRev, 'INSTALLATION_COMPLETE'), ''); + const log = makeFakeBunx(); + + const r = run(); + expect(r.code).toBe(0); + expect(fs.existsSync(strandedRev)).toBe(false); + expect(fs.readFileSync(log, 'utf-8')).toContain('playwright install chromium'); + expect(fs.existsSync(doneFile())).toBe(true); + }); + + test('clean cache → no-op, no bunx call, .done written', () => { + const cleanRev = path.join(pwCache, 'chromium-5678'); + const cleanApp = makeBundle(cleanRev, { plist: CLEAN_PLIST, withExecutable: true }); + const log = makeFakeBunx(); + + const r = run(); + expect(r.code).toBe(0); + expect(r.stderr).toContain('no-op'); + expect(fs.existsSync(log)).toBe(false); // bunx never invoked + expect(fs.existsSync(path.join(cleanApp, 'Contents', 'Info.plist'))).toBe(true); + expect(fs.existsSync(doneFile())).toBe(true); + }); + + test('second run after success → silent no-op (no rescan, no bunx)', () => { + makeBundle(path.join(pwCache, 'chromium-1234'), { plist: POISONED_PLIST }); + const log = makeFakeBunx(); + const r1 = run(); + expect(r1.code).toBe(0); + expect(fs.existsSync(doneFile())).toBe(true); + + fs.rmSync(log, { force: true }); + const r2 = run(); + expect(r2.code).toBe(0); + expect(r2.stderr).toBe(''); + expect(fs.existsSync(log)).toBe(false); + }); + + test('non-Darwin → early exit, cache untouched, no bunx, .done written', () => { + const poisonedApp = makeBundle(path.join(pwCache, 'chromium-1234'), { + plist: POISONED_PLIST, + }); + makeFakeBunx(); + // The script gates on `uname -s` != Darwin; shadow uname on PATH. + fs.writeFileSync(path.join(fakeBinDir, 'uname'), '#!/bin/bash\necho Linux\n', { + mode: 0o755, + }); + + const r = run(); + expect(r.code).toBe(0); + expect(fs.existsSync(poisonedApp)).toBe(true); // nothing removed + expect(fs.existsSync(path.join(fakeBinDir, 'bunx-calls.log'))).toBe(false); + expect(fs.existsSync(doneFile())).toBe(true); + }); +});