/** * Tests for find-browse version check logic * * Tests the checkVersion() and locateBinary() functions directly. * Uses temp directories with mock .version files and cache files. */ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { checkVersion, locateBinary } from '../src/find-browse'; import { mkdtempSync, writeFileSync, rmSync, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; let tempDir: string; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'find-browse-test-')); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); // Clean up test cache try { rmSync('/tmp/gstack-latest-version'); } catch {} }); describe('checkVersion', () => { test('returns null when .version file is missing', () => { const result = checkVersion(tempDir); expect(result).toBeNull(); }); test('returns null when .version file is empty', () => { writeFileSync(join(tempDir, '.version'), ''); const result = checkVersion(tempDir); expect(result).toBeNull(); }); test('returns null when .version has only whitespace', () => { writeFileSync(join(tempDir, '.version'), ' \n'); const result = checkVersion(tempDir); expect(result).toBeNull(); }); test('returns null when local SHA matches remote (cache hit)', () => { const sha = 'a'.repeat(40); writeFileSync(join(tempDir, '.version'), sha); // Write cache with same SHA, recent timestamp const now = Math.floor(Date.now() / 1000); writeFileSync('/tmp/gstack-latest-version', `${sha} ${now}\n`); const result = checkVersion(tempDir); expect(result).toBeNull(); }); test('returns META:UPDATE_AVAILABLE when SHAs differ (cache hit)', () => { const localSha = 'a'.repeat(40); const remoteSha = 'b'.repeat(40); writeFileSync(join(tempDir, '.version'), localSha); // Create a fake browse binary path so resolveSkillDir works const browsePath = join(tempDir, 'browse'); writeFileSync(browsePath, ''); // Write cache with different SHA, recent timestamp const now = Math.floor(Date.now() / 1000); writeFileSync('/tmp/gstack-latest-version', `${remoteSha} ${now}\n`); const result = checkVersion(tempDir); // Result may be null if resolveSkillDir can't determine skill dir from temp path // That's expected — the META signal requires a known skill dir path if (result !== null) { expect(result).toStartWith('META:UPDATE_AVAILABLE'); const jsonStr = result.replace('META:UPDATE_AVAILABLE ', ''); const payload = JSON.parse(jsonStr); expect(payload.current).toBe('a'.repeat(8)); expect(payload.latest).toBe('b'.repeat(8)); expect(payload.command).toContain('git stash'); expect(payload.command).toContain('git reset --hard origin/main'); expect(payload.command).toContain('./setup'); } }); test('uses cached SHA when cache is fresh (< 4hr)', () => { const localSha = 'a'.repeat(40); const remoteSha = 'a'.repeat(40); writeFileSync(join(tempDir, '.version'), localSha); // Cache is 1 hour old — should still be valid const oneHourAgo = Math.floor(Date.now() / 1000) - 3600; writeFileSync('/tmp/gstack-latest-version', `${remoteSha} ${oneHourAgo}\n`); const result = checkVersion(tempDir); expect(result).toBeNull(); // SHAs match }); test('treats expired cache as stale', () => { const localSha = 'a'.repeat(40); writeFileSync(join(tempDir, '.version'), localSha); // Cache is 5 hours old — should be stale const fiveHoursAgo = Math.floor(Date.now() / 1000) - 18000; writeFileSync('/tmp/gstack-latest-version', `${'b'.repeat(40)} ${fiveHoursAgo}\n`); // This will try git ls-remote which may fail in test env — that's OK // The important thing is it doesn't use the stale cache value const result = checkVersion(tempDir); // Result depends on whether git ls-remote succeeds in test environment // If offline, returns null (graceful degradation) expect(result === null || typeof result === 'string').toBe(true); }); test('handles corrupt cache file gracefully', () => { const localSha = 'a'.repeat(40); writeFileSync(join(tempDir, '.version'), localSha); writeFileSync('/tmp/gstack-latest-version', 'garbage data here'); // Should not throw, should treat as stale const result = checkVersion(tempDir); expect(result === null || typeof result === 'string').toBe(true); }); test('handles cache with invalid SHA gracefully', () => { const localSha = 'a'.repeat(40); writeFileSync(join(tempDir, '.version'), localSha); writeFileSync('/tmp/gstack-latest-version', `not-a-sha ${Math.floor(Date.now() / 1000)}\n`); // Invalid SHA should be treated as no cache const result = checkVersion(tempDir); expect(result === null || typeof result === 'string').toBe(true); }); }); describe('locateBinary', () => { test('returns null when no binary exists at known paths', () => { // This test depends on the test environment — if a real binary exists at // ~/.claude/skills/gstack/browse/dist/browse, it will find it. // We mainly test that the function doesn't throw. const result = locateBinary(); expect(result === null || typeof result === 'string').toBe(true); }); test('returns string path when binary exists', () => { const result = locateBinary(); if (result !== null) { expect(existsSync(result)).toBe(true); } }); });