GitNexus/gitnexus/test/unit/setup-antigravity.test.ts
Nilotpal Kashyap 50c6acb108
feat(setup): implement antigravity integration setup and hook adapter… (#1730)
* feat(setup): implement antigravity integration setup and hook adapter for gitnexus

* docs(readme): list Antigravity in supported editors

* test(setup-antigravity): pin platform per-test to fix Windows CI failure

The MCP entry assertion expected `npx` directly, but on Windows
`getMcpEntry()` wraps it as `cmd /c npx ...`, which broke the Windows
runner. Pin platform to darwin in beforeEach so the existing assertion
is deterministic, restore the descriptor in afterEach, and add a
parity test for the win32 cmd-wrapper shape.

* fix(antigravity): align hook adapter to Gemini CLI schema + fix Windows CI

Rebase the Antigravity integration on the canonical Gemini CLI hooks
contract (https://geminicli.com/docs/hooks/reference/), which is the
documented schema Antigravity 2.0 inherits:

- Hook adapter: replace PreToolUse/PostToolUse with the single AfterTool
  event. BeforeTool has no documented context-injection channel in the
  Gemini contract, so augmentation runs in AfterTool where
  hookSpecificOutput.additionalContext is the documented way to append
  text to the tool result the agent reads. Stale-index hints land in the
  same channel (so the agent sees them) and are mirrored to stderr for
  terminal users. Tool-name matcher updated to Gemini CLI snake_case
  (search_file_content|glob|run_shell_command).
- Setup: write hooks to ~/.gemini/settings.json under canonical
  hooks.AfterTool[] (replaces the ad-hoc hooks.json top-level group).
  Polite-neighbor merge preserves existing user hooks. Also copy
  win-rm-list-json.ps1 alongside hook-db-lock-probe.cjs so the Windows
  MCP server ownership probe doesn't silently fail open.
- Tests: 17 regression tests covering MCP write, win32 shape, hook
  schema, polite-neighbor merge, idempotency, adapter context emission,
  stale-index hint, and skill layout.
- README: footnote documenting the AfterTool design choice and a link
  to the Gemini CLI hooks reference.

Windows CI fix: installSkillsTo previously used glob('*.md') +
glob('*/SKILL.md'), which returned zero matches under the Windows
runner's temp paths (8.3 short-name like RUNNER~1). Replace with
fs.readdir + dirent type checks — same behavior, no path quirks. This
fixes the only failing Windows job on the PR.

* fix(antigravity): address PR review — windowsHide, stale docs, dead code

Addresses the production-readiness review findings on PR #1730:

- F1 (blocker): add windowsHide:true to all four spawnSync sites in the
  Antigravity hook adapter (findCanonicalRepoRoot, runGitNexusCli's two
  branches, buildStaleIndexHint) so they don't flash console windows on
  Windows. Matches the fix #1794 already on main for the Claude hook.
- F2 (blocker): update gitnexus/README.md editor table to say AfterTool
  and link the Gemini CLI hooks reference. The published README had
  drifted to the pre-c1872b4 PreToolUse + PostToolUse schema.
- F3: rewrite the stale ~/.gemini block comment in setup.ts. It still
  described the old hooks.json + gitnexus group + grep_search design.
- F4: remove grep_search dead code from extractPattern and its doc
  comment. The registered matcher is search_file_content|glob|run_shell_command,
  so grep_search would never be invoked.
- F5: annotate timeout:10000 with a ms-unit comment noting Gemini CLI
  uses milliseconds (Claude Code uses seconds).
- F6: add the GITNEXUS_DEBUG branch to extractAugmentContext for parity
  with the Claude adapter, so suppressed augment stderr is recoverable.
- F7: stageAdapter test helper now copies win-rm-list-json.ps1 alongside
  the .cjs helpers, so the adapter's Windows lock-probe path isn't a
  silent fail-open in child-process smoke tests.

* test(antigravity): add integration tests and register in cross-platform matrix

Adds end-to-end coverage on top of the unit-level tests, per maintainer
request:

- test/integration/setup-antigravity.test.ts (10 tests): exercises the
  real setupCommand() against a temp HOME with ~/.gemini/antigravity/
  present. Verifies mcp_config.json shape, ~/.gemini/settings.json
  AfterTool entry, adapter + helpers + win-rm-list-json.ps1 copy,
  baked-in cliPath rewrite (issue #108 regression class), skill layout,
  polite-neighbor merge against existing user hooks, idempotency,
  skip-when-absent, corrupt-file safety, and key preservation.
- test/integration/antigravity-hook-e2e.test.ts (19 tests): runs the
  full install-then-execute flow — invokes setupCommand to lay down
  the adapter + helpers, then spawns the INSTALLED adapter as a real
  child process against a temp git repo + .gitnexus/. The source
  adapter cannot be spawned directly (it requires sibling .cjs helpers
  that only live in hooks/claude/); install-then-spawn mirrors the
  production codepath. Covers staleness detection across all five git
  mutation types, --embeddings propagation, polite skip on
  toolResponse.error / exit_code !== 0, augment crash-free behavior,
  cwd validation, corrupted/missing meta.json, unknown event names,
  empty stdin, and the no-.gitnexus deep-nested case.
- scripts/cross-platform-tests.ts: registers all three antigravity
  test files (unit in PLATFORM_LOGIC, two integration files in
  SPAWN_CLI) so Windows and macOS CI exercise them on every run.

* fix(antigravity): review fixes — dedup, silent-failure guard, type coercion, glob filter

- Delete mergeGeminiSettingsHooks (verbatim copy of mergeHooksJsonc),
  replace call site with the original
- Unify geminiHasGitnexusHook into hasGitnexusHook with commandFragment
  parameter; delete the duplicate
- Guard against silent adapter-copy failure: verify the adapter file
  exists before registering the AfterTool hook entry in settings.json;
  surface helper copy errors instead of swallowing
- Fix toolSucceeded type coercion: use Number() so string exit_code
  values from Gemini CLI are handled correctly
- Align glob tool extractPattern with Claude adapter's restrictive
  regex filter (/[*\/]([a-zA-Z][a-zA-Z0-9_-]{2,})/)
- Remove bounds-only toBeGreaterThan(0) assertion (DoD §2.7)
- Add antigravity adapter to HOOK_FILES windowsHide regression list

* chore(autofix): apply prettier + eslint fixes via /autofix command

* chore: trigger CI

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Test <test@example.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-25 14:46:17 +01:00

479 lines
17 KiB
TypeScript

/**
* Regression Tests: Antigravity setup + hook adapter
*
* Covers:
* - setupAntigravity: detection of ~/.gemini/antigravity, MCP write, preserve
* existing keys, corrupt-file handling, skips when not installed.
* - installAntigravityHooks: writes ~/.gemini/settings.json with an
* AfterTool entry under the canonical Gemini CLI / Antigravity 2.0 layout
* (https://geminicli.com/docs/hooks/reference/); copies the adapter and
* lock helpers to ~/.gemini/config/hooks/gitnexus/; idempotent across
* re-runs; preserves existing user hooks ("polite neighbor").
* - installAntigravitySkills: lays out skills under ~/.gemini/antigravity/skills/.
* - hook adapter: AfterTool emits `{hookSpecificOutput.additionalContext}`
* with graph context after search-like tools; emits a stale-index hint
* after a successful `git commit/merge/rebase/cherry-pick/pull`; ignores
* unrelated tools silently.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import { createRequire } from 'module';
const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
.version;
const NPX_REF = `gitnexus@${PKG_VERSION}`;
// vi.hoisted lets the mock factory below (which is hoisted by Vitest) see
// these vi.fn instances. Plain top-level consts would be unreachable at
// hoist time, hence the error this pattern avoids.
const mocks = vi.hoisted(() => ({
execFileMock: vi.fn((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') callback(null, '', '');
}),
execFileSyncMock: vi.fn(() => {
throw new Error('not found');
}),
}));
vi.mock('child_process', async () => {
// Partial mock: real spawnSync is needed for the hook-adapter tests below
// to actually invoke the .cjs script as a child process.
const actual = await vi.importActual<typeof import('child_process')>('child_process');
return {
...actual,
execFile: mocks.execFileMock,
execFileSync: mocks.execFileSyncMock,
};
});
describe('setupAntigravity', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
let platformDescriptor: PropertyDescriptor | undefined;
const setPlatform = (value: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', {
value,
configurable: true,
});
};
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-setup-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Only create ~/.gemini/antigravity — no other editor dirs so their
// setup branches skip and don't pollute assertions.
await fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true });
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
// Default to a non-win32 platform so the MCP entry shape is deterministic
// across CI runners. Tests that need win32 behavior override this.
setPlatform('darwin');
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor);
}
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('writes MCP config to ~/.gemini/antigravity/mcp_config.json', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(
path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json'),
'utf-8',
);
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'npx',
args: ['-y', NPX_REF, 'mcp'],
});
});
it('writes win32 MCP entry with cmd wrapper', async () => {
setPlatform('win32');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(
path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json'),
'utf-8',
);
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'cmd',
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
});
});
it('skips when ~/.gemini/antigravity does not exist', async () => {
await fs.rm(path.join(tempHome, '.gemini'), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(
fs.access(path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json')),
).rejects.toThrow();
await expect(fs.access(path.join(tempHome, '.gemini', 'settings.json'))).rejects.toThrow();
});
it('preserves existing keys in mcp_config.json', async () => {
const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');
await fs.writeFile(
mcpPath,
JSON.stringify({ existingKey: 'keep-me', mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(mcpPath, 'utf-8');
const config = JSON.parse(raw);
expect(config.existingKey).toBe('keep-me');
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('leaves a corrupt mcp_config.json untouched', async () => {
const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');
const corrupt = '{ definitely not json !!!';
await fs.writeFile(mcpPath, corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(mcpPath, 'utf-8');
expect(raw).toBe(corrupt);
});
it('writes ~/.gemini/settings.json with an AfterTool entry under hooks', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.gemini', 'settings.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.hooks.AfterTool).toBeInstanceOf(Array);
expect(config.hooks.AfterTool[0].matcher).toBe('search_file_content|glob|run_shell_command');
expect(config.hooks.AfterTool[0].hooks[0].command).toMatch(/gitnexus-antigravity-hook\.cjs/);
expect(config.hooks.AfterTool[0].hooks[0].timeout).toBe(10000);
expect(config.hooks.AfterTool[0].hooks[0].name).toBe('gitnexus');
});
it('is idempotent — re-running setup does not duplicate hook entries', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.gemini', 'settings.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.hooks.AfterTool).toHaveLength(1);
});
it('preserves existing user hooks in settings.json (polite-neighbor merge)', async () => {
const settingsPath = path.join(tempHome, '.gemini', 'settings.json');
await fs.writeFile(
settingsPath,
JSON.stringify({
theme: 'dark',
hooks: {
AfterTool: [
{
matcher: 'write_file',
hooks: [{ type: 'command', command: 'echo "user-hook"', name: 'user-formatter' }],
},
],
},
}),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(settingsPath, 'utf-8');
const config = JSON.parse(raw);
// Unrelated keys preserved
expect(config.theme).toBe('dark');
// User's hook still present
expect(config.hooks.AfterTool).toHaveLength(2);
expect(config.hooks.AfterTool[0].hooks[0].command).toBe('echo "user-hook"');
// Our entry appended after, not replacing
expect(config.hooks.AfterTool[1].hooks[0].command).toMatch(/gitnexus-antigravity-hook\.cjs/);
});
it('copies adapter + lock helpers to ~/.gemini/config/hooks/gitnexus/', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const destDir = path.join(tempHome, '.gemini', 'config', 'hooks', 'gitnexus');
await expect(
fs.access(path.join(destDir, 'gitnexus-antigravity-hook.cjs')),
).resolves.toBeUndefined();
await expect(fs.access(path.join(destDir, 'hook-lock.cjs'))).resolves.toBeUndefined();
await expect(fs.access(path.join(destDir, 'hook-db-lock-probe.cjs'))).resolves.toBeUndefined();
// Required by hook-db-lock-probe.cjs on Windows; without it the MCP
// server ownership probe silently fails open.
await expect(fs.access(path.join(destDir, 'win-rm-list-json.ps1'))).resolves.toBeUndefined();
});
it('installs skills under ~/.gemini/antigravity/skills/<name>/SKILL.md', async () => {
// Stage a fixture skills tree so the assertion does not depend on
// installSkillsTo's __dirname resolution (which is brittle under
// Vitest on Windows). Production reads the real gitnexus/skills/ dir.
const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills');
await fs.mkdir(fixtureSkillsRoot, { recursive: true });
await fs.writeFile(
path.join(fixtureSkillsRoot, 'gitnexus-test.md'),
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
'utf-8',
);
process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
try {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const skillsDir = path.join(tempHome, '.gemini', 'antigravity', 'skills');
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
const skillDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
expect(skillDirs).toContain('gitnexus-test');
await expect(
fs.access(path.join(skillsDir, 'gitnexus-test', 'SKILL.md')),
).resolves.toBeUndefined();
} finally {
delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
}
});
});
// ─── Hook adapter smoke tests ──────────────────────────────────────
//
// The adapter relies on sibling helpers (hook-lock.cjs, hook-db-lock-probe.cjs).
// For tests we lay out a self-contained copy in a temp dir and spawn it.
const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
const ADAPTER_SRC = path.join(
PROJECT_ROOT,
'hooks',
'antigravity',
'gitnexus-antigravity-hook.cjs',
);
const LOCK_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-lock.cjs');
const PROBE_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-db-lock-probe.cjs');
const WIN_RM_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'win-rm-list-json.ps1');
async function stageAdapter(): Promise<string> {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-adapter-'));
await fs.copyFile(ADAPTER_SRC, path.join(tmp, 'gitnexus-antigravity-hook.cjs'));
await fs.copyFile(LOCK_SRC, path.join(tmp, 'hook-lock.cjs'));
await fs.copyFile(PROBE_SRC, path.join(tmp, 'hook-db-lock-probe.cjs'));
// hook-db-lock-probe.cjs loads this PowerShell script on Windows; without it,
// the lock probe silently fails open and the adapter's Windows DB-lock path
// would be untested in child-process smoke tests.
await fs.copyFile(WIN_RM_SRC, path.join(tmp, 'win-rm-list-json.ps1'));
return path.join(tmp, 'gitnexus-antigravity-hook.cjs');
}
function runAdapter(
hookPath: string,
input: Record<string, any>,
cwd?: string,
): { stdout: string; stderr: string; status: number | null } {
const result = spawnSync(process.execPath, [hookPath], {
input: JSON.stringify(input),
encoding: 'utf-8',
timeout: 10000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
return { stdout: result.stdout || '', stderr: result.stderr || '', status: result.status };
}
describe('gitnexus-antigravity-hook adapter', () => {
let adapter: string;
let workdir: string;
beforeEach(async () => {
adapter = await stageAdapter();
workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-work-'));
});
afterEach(async () => {
await fs.rm(path.dirname(adapter), { recursive: true, force: true });
await fs.rm(workdir, { recursive: true, force: true });
});
it('AfterTool with no .gitnexus/ produces no stdout', async () => {
const { stdout } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
tool_name: 'search_file_content',
tool_input: { pattern: 'someSymbol' },
tool_response: { llmContent: '...' },
cwd: workdir,
},
workdir,
);
expect(stdout.trim()).toBe('');
});
it('AfterTool ignores unrelated tools silently', async () => {
const { stdout, stderr } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
tool_name: 'read_file',
tool_input: { path: 'README.md' },
tool_response: { llmContent: '...' },
cwd: workdir,
},
workdir,
);
expect(stdout.trim()).toBe('');
expect(stderr).not.toMatch(/\[GitNexus\]/);
});
it('AfterTool ignores non-git run_shell_command silently', async () => {
const gnDir = path.join(workdir, '.gitnexus');
await fs.mkdir(gnDir, { recursive: true });
await fs.writeFile(
path.join(gnDir, 'meta.json'),
JSON.stringify({ lastCommit: 'deadbeef', stats: {} }),
'utf-8',
);
const { stdout, stderr } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
tool_name: 'run_shell_command',
tool_input: { command: 'npm test' },
tool_response: { llmContent: '...' },
cwd: workdir,
},
workdir,
);
expect(stdout.trim()).toBe('');
expect(stderr).not.toMatch(/\[GitNexus\]/);
});
it('AfterTool emits stale-index hint after a successful git commit', async () => {
// Initialize a git repo and a stale .gitnexus/meta.json.
spawnSync('git', ['init', '-q'], { cwd: workdir });
spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: workdir });
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: workdir });
await fs.writeFile(path.join(workdir, 'a.txt'), 'hello', 'utf-8');
spawnSync('git', ['add', '.'], { cwd: workdir });
spawnSync('git', ['commit', '-q', '-m', 'init'], { cwd: workdir });
const gnDir = path.join(workdir, '.gitnexus');
await fs.mkdir(gnDir, { recursive: true });
await fs.writeFile(
path.join(gnDir, 'meta.json'),
JSON.stringify({ lastCommit: '0000000000000000000000000000000000000000', stats: {} }),
'utf-8',
);
const { stdout, stderr } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
tool_name: 'run_shell_command',
tool_input: { command: 'git commit -m "x"' },
tool_response: { llmContent: '[committed]' },
cwd: workdir,
},
workdir,
);
// Hint surfaces both via the agent-visible channel and stderr (terminal).
expect(stderr).toMatch(/\[GitNexus\] index is stale/);
expect(stderr).toMatch(/gitnexus analyze/);
const parsed = JSON.parse(stdout);
expect(parsed.hookSpecificOutput.hookEventName).toBe('AfterTool');
expect(parsed.hookSpecificOutput.additionalContext).toMatch(/index is stale/);
});
it('AfterTool skips augment when the tool failed', async () => {
const gnDir = path.join(workdir, '.gitnexus');
await fs.mkdir(gnDir, { recursive: true });
await fs.writeFile(
path.join(gnDir, 'meta.json'),
JSON.stringify({ lastCommit: 'deadbeef', stats: {} }),
'utf-8',
);
const { stdout } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
tool_name: 'search_file_content',
tool_input: { pattern: 'someSymbol' },
tool_response: { error: 'boom' },
cwd: workdir,
},
workdir,
);
expect(stdout.trim()).toBe('');
});
it('ignores unknown tool names without crashing', async () => {
const { status } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
tool_name: 'unknown_tool',
tool_input: {},
tool_response: { llmContent: '' },
cwd: workdir,
},
workdir,
);
expect(status).toBe(0);
});
it('does not crash on empty stdin', () => {
const result = spawnSync(process.execPath, [adapter], {
input: '',
encoding: 'utf-8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
});
expect(result.status).toBe(0);
});
});