Files
msd-core/tests/antigravity-install.test.cjs
Tom Boucher f970c09bf3 refactor(3664): migrate bin/install.js install/uninstall to Runtime Artifact Layout Module (#3674)
Phase 2 of #3660 / ADR-3660. Routes both lifecycle verbs through the
Runtime Artifact Layout Module landed in Phase 1 (#3663):

- Add installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)
  and uninstallRuntimeArtifacts(runtime, configDir, scope) as the public
  orchestrators. Both pre-prune stale gsd-* entries before staged copy;
  installRuntimeArtifacts brackets the prune+copy with preserveUserArtifacts /
  restoreUserArtifacts so user-owned content (e.g. gsd-dev-preferences) survives
  wipe-and-replace for claude/qwen/hermes runtimes.
- Add applyRuntimeContentRewritesInPlace as the per-runtime path/branding
  post-stage step (preserves byte-output equivalence with the legacy
  copyCommandsAs* pipeline, including Qwen/Hermes branding rewrites).
- Add _copyStaged, _removeGsdEntries kind-aware filesystem helpers.
- Add _runLegacyInstallMigrations, _runLegacyUninstallCleanup as thin
  dispatchers over existing ADR-0008 legacy migrations (Hermes flat->nested
  per #2841, dev-preferences-as-skill per #2973). For Hermes, also clean up
  the intermediate skills/gsd/gsd-*/ layout that pre-Phase-2 installs left
  on disk.
- Delete the 9 copyCommandsAs*Skills functions (Codex / Cursor / Windsurf /
  Trae / CodeBuddy / Copilot / Claude / Antigravity / Augment) and the
  _copyCommandsAsSkillsViaConverter helper. All test entry points migrated
  to call installRuntimeArtifacts directly through the unified seam.
- Collapse the 9-branch uninstall ladder to one uninstallRuntimeArtifacts
  call plus preserved non-layout side-effects (Codex TOML, Copilot
  instructions, hooks).
- Unify install dispatcher: a single _isSkillsRuntime gate routes all 11
  skills runtimes through installRuntimeArtifacts for both full and core/
  minimal profiles. Removes 11 per-runtime if-else branches (3 minimal-mode
  shim branches + 8 dead after-the-gate branches).

Net delta on bin/install.js: 11,495 -> 11,174 (-321 LOC).

New tests:
- tests/install-uninstall-layout-loop.test.cjs (34 tests) - per-runtime
  fixture assertions on install/uninstall/legacy-migration ordering.
- tests/install-hermes-regressions.test.cjs (6 tests) - covers the six
  defects surfaced by iterative review: Hermes upgrade leaves stale dirs,
  --hermes --profile=core fall-through, --qwen --profile=core fall-through,
  minimal-mode dev-preferences migration skipped (Hermes/Qwen/Claude-global),
  and ordering bug in _runLegacyInstallMigrations.

Existing tests (10,038 prior + 40 new) all green: 10,078/10,078 pass.

Refs #3664

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:00:59 -04:00

422 lines
18 KiB
JavaScript

// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* GSD Tools Tests - Antigravity Install Plumbing
*
* Tests for Antigravity runtime directory resolution, config paths,
* content conversion functions, and integration with the multi-runtime installer.
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const os = require('os');
const fs = require('fs');
const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
convertClaudeToAntigravityContent,
convertClaudeCommandToAntigravitySkill,
convertClaudeAgentToAntigravityAgent,
writeManifest,
installRuntimeArtifacts,
} = require('../bin/install.js');
// ─── Profile resolution for installRuntimeArtifacts tests ────────────────────
const _gsdLibDir = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib');
const { loadSkillsManifest, resolveProfile } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
const _manifest = loadSkillsManifest();
const resolvedProfileFull = resolveProfile({ modes: [], manifest: _manifest });
// ─── getDirName ─────────────────────────────────────────────────────────────────
describe('getDirName (Antigravity)', () => {
test('returns .agent for antigravity', () => {
assert.strictEqual(getDirName('antigravity'), '.agent');
});
test('does not break existing runtimes', () => {
assert.strictEqual(getDirName('claude'), '.claude');
assert.strictEqual(getDirName('opencode'), '.opencode');
assert.strictEqual(getDirName('gemini'), '.gemini');
assert.strictEqual(getDirName('kilo'), '.kilo');
assert.strictEqual(getDirName('codex'), '.codex');
assert.strictEqual(getDirName('copilot'), '.github');
});
});
// ─── getGlobalDir ───────────────────────────────────────────────────────────────
describe('getGlobalDir (Antigravity)', () => {
let savedEnv;
beforeEach(() => {
savedEnv = process.env.ANTIGRAVITY_CONFIG_DIR;
delete process.env.ANTIGRAVITY_CONFIG_DIR;
});
afterEach(() => {
if (savedEnv !== undefined) {
process.env.ANTIGRAVITY_CONFIG_DIR = savedEnv;
} else {
delete process.env.ANTIGRAVITY_CONFIG_DIR;
}
});
test('returns ~/.gemini/antigravity by default', () => {
const result = getGlobalDir('antigravity');
assert.strictEqual(result, path.join(os.homedir(), '.gemini', 'antigravity'));
});
test('respects ANTIGRAVITY_CONFIG_DIR env var', () => {
const customDir = path.join(os.homedir(), 'custom-ag');
process.env.ANTIGRAVITY_CONFIG_DIR = customDir;
const result = getGlobalDir('antigravity');
assert.strictEqual(result, customDir);
});
test('explicit config-dir overrides env var', () => {
process.env.ANTIGRAVITY_CONFIG_DIR = path.join(os.homedir(), 'from-env');
const explicit = path.join(os.homedir(), 'explicit-ag');
const result = getGlobalDir('antigravity', explicit);
assert.strictEqual(result, explicit);
});
test('does not change Claude Code global dir', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
});
});
// ─── getConfigDirFromHome ───────────────────────────────────────────────────────
describe('getConfigDirFromHome (Antigravity)', () => {
test('returns .agent for local installs', () => {
assert.strictEqual(getConfigDirFromHome('antigravity', false), "'.agent'");
});
test('returns .gemini, antigravity for global installs', () => {
assert.strictEqual(getConfigDirFromHome('antigravity', true), "'.gemini', 'antigravity'");
});
test('does not change other runtimes', () => {
assert.strictEqual(getConfigDirFromHome('claude', true), "'.claude'");
assert.strictEqual(getConfigDirFromHome('gemini', true), "'.gemini'");
assert.strictEqual(getConfigDirFromHome('kilo', true), "'.config', 'kilo'");
assert.strictEqual(getConfigDirFromHome('copilot', true), "'.copilot'");
});
});
// ─── convertClaudeToAntigravityContent ─────────────────────────────────────────
describe('convertClaudeToAntigravityContent', () => {
describe('global install path replacements', () => {
test('replaces ~/. claude/ with ~/.gemini/antigravity/', () => {
const input = 'See ~/.claude/get-shit-done/workflows/';
const result = convertClaudeToAntigravityContent(input, true);
assert.ok(result.includes('~/.gemini/antigravity/get-shit-done/workflows/'), result);
assert.ok(!result.includes('~/.claude/'), result);
});
test('replaces $HOME/.claude/ with $HOME/.gemini/antigravity/', () => {
const input = 'path.join($HOME/.claude/get-shit-done)';
const result = convertClaudeToAntigravityContent(input, true);
assert.ok(result.includes('$HOME/.gemini/antigravity/'), result);
assert.ok(!result.includes('$HOME/.claude/'), result);
});
});
describe('local install path replacements', () => {
test('replaces ~/.claude/ with .agent/ for local installs', () => {
const input = 'See ~/.claude/get-shit-done/';
const result = convertClaudeToAntigravityContent(input, false);
assert.ok(result.includes('.agent/get-shit-done/'), result);
assert.ok(!result.includes('~/.claude/'), result);
});
test('replaces ./.claude/ with ./.agent/', () => {
const input = 'path ./.claude/hooks/gsd-check-update.js';
const result = convertClaudeToAntigravityContent(input, false);
assert.ok(result.includes('./.agent/hooks/'), result);
assert.ok(!result.includes('./.claude/'), result);
});
test('replaces .claude/ with .agent/', () => {
const input = 'node .claude/hooks/gsd-statusline.js';
const result = convertClaudeToAntigravityContent(input, false);
assert.ok(result.includes('.agent/hooks/gsd-statusline.js'), result);
assert.ok(!result.includes('.claude/'), result);
});
});
describe('command name conversion', () => {
test('converts /gsd:command to /gsd-command', () => {
const input = 'Run /gsd:new-project to start';
const result = convertClaudeToAntigravityContent(input, true);
assert.ok(result.includes('/gsd-new-project'), result);
assert.ok(!result.includes('gsd:'), result);
});
test('converts all gsd: references', () => {
const input = '/gsd:plan-phase and /gsd:execute-phase';
const result = convertClaudeToAntigravityContent(input, false);
assert.ok(result.includes('/gsd-plan-phase'), result);
assert.ok(result.includes('/gsd-execute-phase'), result);
});
});
test('does not modify unrelated content', () => {
const input = 'This is a plain text description with no paths.';
const result = convertClaudeToAntigravityContent(input, false);
assert.strictEqual(result, input);
});
});
// ─── convertClaudeCommandToAntigravitySkill ─────────────────────────────────────
describe('convertClaudeCommandToAntigravitySkill', () => {
const claudeCommand = `---
name: gsd:new-project
description: Initialize a new GSD project with requirements and roadmap
argument-hint: "[project-name]"
allowed-tools:
- Read
- Write
- Bash
- Agent
---
Initialize new project at ~/.claude/get-shit-done/workflows/new-project.md
`;
test('produces name and description only in frontmatter', () => {
const result = convertClaudeCommandToAntigravitySkill(claudeCommand, 'gsd-new-project', false);
const fm = parseFrontmatter(result);
assert.equal(fm.name, 'gsd-new-project', result);
assert.equal(fm.description, 'Initialize a new GSD project with requirements and roadmap', result);
assert.ok(!('allowed-tools' in fm), 'no allowed-tools field');
assert.ok(!('argument-hint' in fm), 'no argument-hint field');
});
test('applies path replacement in body', () => {
const result = convertClaudeCommandToAntigravitySkill(claudeCommand, 'gsd-new-project', false);
assert.ok(result.includes('.agent/get-shit-done/'), result);
assert.ok(!result.includes('~/.claude/'), result);
});
test('uses provided skillName for name field', () => {
const result = convertClaudeCommandToAntigravitySkill(claudeCommand, 'gsd-custom-name', false);
assert.ok(result.includes('name: gsd-custom-name'), result);
});
test('converts gsd: command references in body', () => {
const content = `---
name: test
description: test skill
---
Run /gsd:new-project to get started.
`;
const result = convertClaudeCommandToAntigravitySkill(content, 'gsd-test', false);
assert.ok(result.includes('/gsd-new-project'), result);
assert.ok(!result.includes('gsd:'), result);
});
test('returns unchanged content when no frontmatter', () => {
const noFm = 'Just some text without frontmatter.';
const result = convertClaudeCommandToAntigravitySkill(noFm, 'gsd-test', false);
// Path replacements still apply, but no frontmatter transformation
assert.ok(!result.startsWith('---'), result);
});
});
// ─── convertClaudeAgentToAntigravityAgent ──────────────────────────────────────
describe('convertClaudeAgentToAntigravityAgent', () => {
const claudeAgent = `---
name: gsd-executor
description: Executes GSD plans with atomic commits
tools: Read, Write, Edit, Bash, Glob, Grep, Task
color: blue
---
Execute plans from ~/.claude/get-shit-done/workflows/execute-phase.md
`;
test('preserves name and description', () => {
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
const fm = parseFrontmatter(result);
assert.equal(fm.name, 'gsd-executor', result);
assert.equal(fm.description, 'Executes GSD plans with atomic commits', result);
});
test('maps Claude tools to Gemini tool names', () => {
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
// Read → read_file, Bash → run_shell_command
assert.ok(result.includes('read_file'), result);
assert.ok(result.includes('run_shell_command'), result);
// Original Claude names should not appear in tools line
const fmEnd = result.indexOf('---', 3);
const frontmatter = result.slice(0, fmEnd);
assert.ok(!frontmatter.includes('tools: Read,'), frontmatter);
});
test('preserves color field', () => {
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
assert.ok(result.includes('color: blue'), result);
});
test('applies path replacement in body', () => {
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
assert.ok(result.includes('.agent/get-shit-done/'), result);
assert.ok(!result.includes('~/.claude/'), result);
});
test('uses global path for global installs', () => {
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, true);
assert.ok(result.includes('~/.gemini/antigravity/get-shit-done/'), result);
});
test('excludes Task tool (filtered by convertGeminiToolName)', () => {
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
// Task is excluded by convertGeminiToolName (returns null for Task)
const fmEnd = result.indexOf('---', 3);
const frontmatter = result.slice(0, fmEnd);
assert.ok(!frontmatter.includes('Task'), frontmatter);
});
});
// ─── installRuntimeArtifacts (antigravity integration) ────────────────────────
describe('installRuntimeArtifacts (antigravity integration)', () => {
// Pivoted from copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false) shim to
// installRuntimeArtifacts('antigravity', configDir, 'local'|'global', resolvedProfileFull).
// Output layout: <configDir>/skills/gsd-<stem>/SKILL.md (destSubpath='skills', prefix='gsd-').
// stageSkillsForRuntimeAsSkills does NOT recurse into subdirectories; the real
// commands/gsd/ directory has no subdirs, so subdir-handling is not a production path.
let configDir;
beforeEach(() => {
configDir = createTempDir('gsd-ag-test-');
});
afterEach(() => {
fs.rmSync(configDir, { recursive: true, force: true });
});
test('creates skills directory', () => {
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
assert.ok(fs.existsSync(path.join(configDir, 'skills')));
});
test('creates one skill directory per command with SKILL.md', () => {
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
const skillsDir = path.join(configDir, 'skills');
const skillDir = path.join(skillsDir, 'gsd-new-project');
assert.ok(fs.existsSync(skillDir), 'skill dir should exist');
assert.ok(fs.existsSync(path.join(skillDir, 'SKILL.md')), 'SKILL.md should exist');
});
// NOTE: 'handles subdirectory commands with prefixed names' (gsd-subdir-sub-command) is
// DELETED. stageSkillsForRuntimeAsSkills processes only flat .md files in commands/gsd/;
// the real commands/gsd/ directory contains no subdirectories. This test had no production
// code path and cannot be expressed through the installRuntimeArtifacts seam.
test('SKILL.md has minimal frontmatter (name + description only)', () => {
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-new-project', 'SKILL.md'), 'utf8');
const fm = parseFrontmatter(content);
assert.equal(fm.name, 'gsd-new-project', content);
assert.ok(fm.description, 'description field is present');
assert.ok(!('allowed-tools' in fm), 'no allowed-tools field');
});
test('SKILL.md body has paths converted for local install', () => {
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-new-project', 'SKILL.md'), 'utf8');
// gsd: → gsd- conversion
assert.ok(!content.includes('gsd:'), content);
});
test('removes old gsd-* skill dirs before reinstalling', () => {
const skillsDir = path.join(configDir, 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
// Stale GSD-managed dir must be pruned
const staleDir = path.join(skillsDir, 'gsd-old-stale-skill');
fs.mkdirSync(staleDir, { recursive: true });
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), 'stale content');
// Non-GSD dir should survive
const otherDir = path.join(skillsDir, 'my-custom-skill');
fs.mkdirSync(otherDir, { recursive: true });
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-new-project')), 'real skill dir written');
assert.ok(!fs.existsSync(staleDir), 'stale gsd-* dir removed by pre-prune');
});
test('does not remove non-gsd skill dirs', () => {
// Create a non-GSD skill dir before install
const skillsDir = path.join(configDir, 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
const otherDir = path.join(skillsDir, 'my-custom-skill');
fs.mkdirSync(otherDir, { recursive: true });
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
assert.ok(fs.existsSync(otherDir), 'non-GSD skill dir should be preserved');
});
});
// ─── writeManifest (Antigravity) ───────────────────────────────────────────────
describe('writeManifest (Antigravity)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-manifest-ag-');
// Create minimal structure
const skillsDir = path.join(tmpDir, 'skills', 'gsd-help');
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(path.join(skillsDir, 'SKILL.md'), '---\nname: gsd-help\ndescription: Help\n---\n');
const gsdDir = path.join(tmpDir, 'get-shit-done');
fs.mkdirSync(gsdDir, { recursive: true });
fs.writeFileSync(path.join(gsdDir, 'VERSION'), '1.0.0');
const agentsDir = path.join(tmpDir, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), '---\nname: gsd-executor\n---\n');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('writes manifest JSON file', () => {
writeManifest(tmpDir, 'antigravity');
const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json');
assert.ok(fs.existsSync(manifestPath), 'manifest file should exist');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
assert.ok(manifest.version, 'should have version');
assert.ok(manifest.files, 'should have files');
});
test('manifest includes skills in skills/ directory', () => {
writeManifest(tmpDir, 'antigravity');
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8'));
const skillFiles = Object.keys(manifest.files).filter(f => f.startsWith('skills/'));
assert.ok(skillFiles.length > 0, 'should have skill files in manifest');
});
test('manifest includes agent files', () => {
writeManifest(tmpDir, 'antigravity');
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8'));
const agentFiles = Object.keys(manifest.files).filter(f => f.startsWith('agents/'));
assert.ok(agentFiles.length > 0, 'should have agent files in manifest');
});
});