diff --git a/test/gstack-upgrade-migration-v1_78_0_0.test.ts b/test/gstack-upgrade-migration-v1_78_0_0.test.ts new file mode 100644 index 000000000..d7bcb4904 --- /dev/null +++ b/test/gstack-upgrade-migration-v1_78_0_0.test.ts @@ -0,0 +1,117 @@ +/** + * v1.78.0.0 migration — carry feature-discovery acknowledgement markers + * (.feature-prompted-continuous-checkpoint, .feature-prompted-model-overlay) + * from the gstack install dir to GSTACK_HOME (#2728 absorption). + * + * Exercises the script in hermetic mkdtemp roots via GSTACK_INSTALL_DIR / + * GSTACK_HOME overrides. Covers: copy-when-absent, destination-wins, + * clean no-op, and idempotent re-run. + */ + +import { describe, test, expect, beforeEach, afterAll } 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.78.0.0.sh'); + +const MARKERS = [ + '.feature-prompted-continuous-checkpoint', + '.feature-prompted-model-overlay', +] as const; + +const tmpRoots: string[] = []; +let tmpHome: string; +let installDir: string; +let gstackHome: string; + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-v1.78-')); + tmpRoots.push(tmpHome); + installDir = path.join(tmpHome, 'install'); + gstackHome = path.join(tmpHome, '.gstack'); + fs.mkdirSync(installDir, { recursive: true }); + // gstackHome deliberately NOT pre-created: the script's own `mkdir -p` is + // part of the contract (fresh installs have no ~/.gstack yet). +}); + +afterAll(() => { + for (const dir of tmpRoots) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function run(): { code: number; stdout: string; stderr: string } { + const r = spawnSync('bash', [MIGRATION], { + env: { + PATH: '/usr/bin:/bin', + HOME: tmpHome, + GSTACK_INSTALL_DIR: installDir, + GSTACK_HOME: gstackHome, + }, + encoding: 'utf-8', + cwd: tmpHome, + timeout: 30_000, + }); + return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' }; +} + +describe('v1.78.0.0 migration — feature markers → GSTACK_HOME', () => { + test('markers in INSTALL_DIR, absent in GSTACK_HOME → created in GSTACK_HOME, source untouched', () => { + for (const m of MARKERS) fs.writeFileSync(path.join(installDir, m), 'source-content\n'); + + const r = run(); + expect(r.code).toBe(0); + for (const m of MARKERS) { + expect(fs.existsSync(path.join(gstackHome, m))).toBe(true); + expect(r.stdout).toContain(`migrated: ${m}`); + // Copy, not move: the install-dir marker stays, content intact. + expect(fs.readFileSync(path.join(installDir, m), 'utf-8')).toBe('source-content\n'); + } + }); + + test('marker already in GSTACK_HOME → NOT overwritten (destination wins); missing sibling still migrates', () => { + fs.mkdirSync(gstackHome, { recursive: true }); + const [checkpoint, overlay] = MARKERS; + fs.writeFileSync(path.join(installDir, checkpoint), 'install-side\n'); + fs.writeFileSync(path.join(gstackHome, checkpoint), 'dest-side\n'); + fs.writeFileSync(path.join(installDir, overlay), ''); + + const r = run(); + expect(r.code).toBe(0); + expect(fs.readFileSync(path.join(gstackHome, checkpoint), 'utf-8')).toBe('dest-side\n'); + expect(r.stdout).not.toContain(`migrated: ${checkpoint}`); + expect(fs.existsSync(path.join(gstackHome, overlay))).toBe(true); + expect(r.stdout).toContain(`migrated: ${overlay}`); + }); + + test('no markers anywhere → clean no-op, exit 0, nothing created', () => { + const r = run(); + expect(r.code).toBe(0); + expect(r.stdout).toBe(''); + expect(r.stderr).toBe(''); + for (const m of MARKERS) { + expect(fs.existsSync(path.join(gstackHome, m))).toBe(false); + } + }); + + test('idempotent: second run exits 0, migrates nothing new, end state unchanged', () => { + for (const m of MARKERS) fs.writeFileSync(path.join(installDir, m), ''); + + const r1 = run(); + expect(r1.code).toBe(0); + const stateAfterFirst = MARKERS.map((m) => [ + fs.existsSync(path.join(gstackHome, m)), + fs.readFileSync(path.join(gstackHome, m), 'utf-8'), + ]); + + const r2 = run(); + expect(r2.code).toBe(0); + expect(r2.stdout).toBe(''); // destinations exist now — no "migrated:" lines + const stateAfterSecond = MARKERS.map((m) => [ + fs.existsSync(path.join(gstackHome, m)), + fs.readFileSync(path.join(gstackHome, m), 'utf-8'), + ]); + expect(stateAfterSecond).toEqual(stateAfterFirst); + }); +});