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);
+ });
+});