/**
* Unit tests for deploy hint utilities (P7)
*
* Tests CompactDeployHint, buildCompactDeployHint(), getDeployState(), updateDeployState()
*/
import { expect } from 'chai';
import os from 'os';
import path from 'path';
import fs from 'fs';
import { execSync } from 'child_process';
import {
buildCompactDeployHint,
getDeployState,
updateDeployState,
type CompactDeployHint,
} from '../../src/utils/gitStatus.js';
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
/** Create a temporary git repo with one commit so getGitHead() returns a real SHA. */
function makeTempGitRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-gas-test-'));
execSync('git init', { cwd: dir });
execSync('git config user.email "test@test.com"', { cwd: dir });
execSync('git config user.name "Test"', { cwd: dir });
fs.writeFileSync(path.join(dir, 'hello.txt'), 'hello');
execSync('git add .', { cwd: dir });
execSync('git commit -m "init"', { cwd: dir });
return dir;
}
function cleanupRepo(dir: string) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
}
// Unique scriptId per test run to avoid cross-test state pollution
let testScriptIdCounter = 1000;
function uniqueScriptId(): string {
return `test-script-deploy-hints-${testScriptIdCounter++}-${Date.now()}`;
}
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
describe('deployHints - getDeployState / updateDeployState', () => {
it('returns null hash for an unknown scriptId', () => {
const scriptId = uniqueScriptId();
const state = getDeployState(scriptId);
expect(state).to.deep.equal({ lastDeployedHash: null });
});
it('round-trips a hash via updateDeployState / getDeployState', () => {
const scriptId = uniqueScriptId();
const hash = 'abc123def456abc123def456abc123def456abc1';
updateDeployState(scriptId, hash);
const state = getDeployState(scriptId);
expect(state.lastDeployedHash).to.equal(hash);
});
it('overwrites existing state on second updateDeployState call', () => {
const scriptId = uniqueScriptId();
updateDeployState(scriptId, 'first-hash');
updateDeployState(scriptId, 'second-hash');
expect(getDeployState(scriptId).lastDeployedHash).to.equal('second-hash');
});
it('different scriptIds have independent state', () => {
const id1 = uniqueScriptId();
const id2 = uniqueScriptId();
updateDeployState(id1, 'hash-for-id1');
expect(getDeployState(id2).lastDeployedHash).to.be.null;
});
});
describe('deployHints - buildCompactDeployHint', () => {
let repoDir: string;
before(() => {
repoDir = makeTempGitRepo();
});
after(() => {
cleanupRepo(repoDir);
});
it('emits a LOW hint after commit when lastDeployedHash is null (fresh session)', async () => {
const scriptId = uniqueScriptId();
// No updateDeployState called → lastDeployedHash is null
const hint = await buildCompactDeployHint(scriptId, repoDir, 'commit');
expect(hint).to.not.be.null;
const h = hint as CompactDeployHint;
expect(h.staging).to.equal('stale');
expect(h.urgency).to.equal('LOW');
expect(h.hint).to.include('staging');
});
it('emits a MEDIUM hint after finish when lastDeployedHash is null', async () => {
const scriptId = uniqueScriptId();
const hint = await buildCompactDeployHint(scriptId, repoDir, 'finish');
expect(hint).to.not.be.null;
const h = hint as CompactDeployHint;
expect(h.urgency).to.equal('MEDIUM');
expect(h.hint).to.include('merged');
});
it('suppresses hint when lastDeployedHash matches current HEAD', async () => {
const scriptId = uniqueScriptId();
// Get the actual HEAD of our test repo
const head = execSync('git rev-parse HEAD', { cwd: repoDir }).toString().trim();
updateDeployState(scriptId, head);
const hint = await buildCompactDeployHint(scriptId, repoDir, 'commit');
expect(hint).to.be.null;
});
it('emits hint when lastDeployedHash differs from current HEAD (stale deploy)', async () => {
const scriptId = uniqueScriptId();
updateDeployState(scriptId, 'old-hash-that-is-not-current-head');
const hint = await buildCompactDeployHint(scriptId, repoDir, 'commit');
expect(hint).to.not.be.null;
expect((hint as CompactDeployHint).urgency).to.equal('LOW');
});
it('emits hint for non-existent git repo path (conservative: cannot confirm staging is current)', async () => {
// When getGitHead fails, currentHead is null → cannot confirm lastDeployedHash === currentHead
// → conservative: emit hint rather than silently suppress
const scriptId = uniqueScriptId();
const hint = await buildCompactDeployHint(scriptId, '/tmp/nonexistent-repo-xyz-deploy-hints', 'commit');
// Either a hint or null is acceptable — the key thing is no throw
// In practice the function returns a hint (conservative) or null if it catches an outer error
// Both are valid graceful degradation behaviors
expect(hint === null || (hint !== null && hint.staging === 'stale')).to.be.true;
});
});