Files
msd-core/tests/antigravity-install.test.cjs
Tom Boucher 55298b2f70 fix(#2876): yamlQuote SKILL.md description for Copilot/Antigravity/Trae/CodeBuddy (#2881)
* fix(#2876): yamlQuote description in Copilot/Antigravity/Trae/CodeBuddy SKILL.md

A description starting with `[BETA]` (or any YAML flow indicator —
`{`, `*`, `&`, `!`, `|`, `>`, `%`, `@`, backtick) is parsed as a flow
sequence/mapping by YAML 1.2-strict loaders. gh-copilot's frontmatter
loader fails closed:

  ✖ ~/.copilot/skills/gsd-ultraplan-phase/SKILL.md: failed to parse YAML
    frontmatter: Unexpected scalar at node end at line 2, column 21:
    description: [BETA] Offload plan phase to Claude Code's ultraplan…

Six emission sites in `bin/install.js` re-wrote the description without
quoting, while the Claude variant (`convertClaudeCommandToClaudeSkill`)
already routed it through `yamlQuote`. Brought all six in line:

- convertClaudeCommandToCopilotSkill
- convertClaudeAgentToCopilotAgent
- convertClaudeCommandToAntigravitySkill
- convertClaudeAgentToAntigravityAgent
- convertClaudeCommandToTraeSkill
- convertClaudeCommandToCodebuddySkill

Each now wraps the value in `yamlQuote(...)` so any leading character is
parser-safe.

Regression test (tests/bug-2876-skill-frontmatter-quote.test.cjs) drives
the four command converters and two agent converters through the
reporter's exact "[BETA] …" description plus a grab-bag of YAML flow
indicators, asserting the emitted `description:` value is a quoted YAML
scalar. Also round-trips the value through `JSON.parse` for converters
that don't apply runtime-name substitution to confirm fidelity.

Updated 7 pre-existing substring assertions in copilot-install.test.cjs
and antigravity-install.test.cjs that hard-coded the unquoted form.
Round trip: 5893/5893 pass on `npm test`.

Closes #2876

* test(#2876): structurally parse frontmatter instead of substring-grep

Addresses CodeRabbit's two nitpicks on PR #2881: the pre-existing
substring assertions in copilot-install.test.cjs (4 sites) and
antigravity-install.test.cjs (3 sites) only got bumped from the unquoted
form (`description: Diagnose...`) to the quoted-prefix form
(`description: "Diagnose...`). Both are still raw-string checks against
rendered YAML and drift on any quoting/order change — exactly what the
project's CONTRIBUTING.md "no-source-grep" testing standard exists to
prevent.

Add `parseFrontmatter()` to tests/helpers.cjs — a small parser that
handles the YAML scalar forms the install converters emit (double-quoted
JSON, single-quoted with `''` escape, bare). Throws if the content has
no closed `---` block so a regression in the emitter shape fails loudly
rather than silently returning {}.

Refactor the 7 description-substring sites to compare on parsed values:
the assertion now reads as `fm.description === 'Diagnose planning
directory health'` rather than `result.includes('description: "Diagnose
planning directory health')`. Same coverage of the #2876 quoting
behavior, no coupling to byte-level quote style.

`npm test`: 5893/5893 pass.

Closes #2876

* test(#2876): make parseFrontmatter delimiter check CRLF/whitespace tolerant

CR nitpick on PR #2881 (review at 03:08:08Z): parseFrontmatter()
splits on '\n' and compares each line strictly to '---'. A
Windows-authored skill file (CRLF endings) leaves a trailing '\r'
on every line, so '---\r' fails the equality check, and the helper
throws "no closed --- block" on perfectly valid input. Same problem
with whitespace-padded delimiter lines.

Switch to splitting on /\r?\n/ and comparing the trimmed line.
Helper is used by tests/copilot-install.test.cjs and
tests/antigravity-install.test.cjs, so this also de-flakes those
suites on Windows runners.

5893/5893 on `npm test`.
2026-04-29 23:27:27 -04:00

427 lines
17 KiB
JavaScript

/**
* 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,
copyCommandsAsAntigravitySkills,
writeManifest,
} = require('../bin/install.js');
// ─── 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);
});
});
// ─── copyCommandsAsAntigravitySkills ───────────────────────────────────────────
describe('copyCommandsAsAntigravitySkills', () => {
let tmpDir;
let srcDir;
let skillsDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-ag-test-');
srcDir = path.join(tmpDir, 'commands', 'gsd');
skillsDir = path.join(tmpDir, 'skills');
fs.mkdirSync(srcDir, { recursive: true });
// Create a sample command file
fs.writeFileSync(path.join(srcDir, 'new-project.md'), `---
name: gsd:new-project
description: Initialize a new project
allowed-tools:
- Read
- Write
---
Run /gsd:new-project to start.
`);
// Create a subdirectory command
const subDir = path.join(srcDir, 'subdir');
fs.mkdirSync(subDir, { recursive: true });
fs.writeFileSync(path.join(subDir, 'sub-command.md'), `---
name: gsd:sub-command
description: A sub-command
allowed-tools:
- Read
---
Body text.
`);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('creates skills directory', () => {
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
assert.ok(fs.existsSync(skillsDir));
});
test('creates one skill directory per command with SKILL.md', () => {
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
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');
});
test('handles subdirectory commands with prefixed names', () => {
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
const subSkillDir = path.join(skillsDir, 'gsd-subdir-sub-command');
assert.ok(fs.existsSync(subSkillDir), 'subdirectory skill dir should exist');
});
test('SKILL.md has minimal frontmatter (name + description only)', () => {
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
const content = fs.readFileSync(path.join(skillsDir, 'gsd-new-project', 'SKILL.md'), 'utf8');
const fm = parseFrontmatter(content);
assert.equal(fm.name, 'gsd-new-project', content);
assert.equal(fm.description, 'Initialize a new project', content);
assert.ok(!('allowed-tools' in fm), 'no allowed-tools field');
});
test('SKILL.md body has paths converted for local install', () => {
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
const content = fs.readFileSync(path.join(skillsDir, 'gsd-new-project', 'SKILL.md'), 'utf8');
// gsd: → gsd- conversion
assert.ok(!content.includes('gsd:'), content);
});
test('removes old gsd-* skill dirs before reinstalling', () => {
// Create a stale skill dir
const staleDir = path.join(skillsDir, 'gsd-old-skill');
fs.mkdirSync(staleDir, { recursive: true });
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '---\nname: old\n---\n');
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
assert.ok(!fs.existsSync(staleDir), 'stale skill dir should be removed');
});
test('does not remove non-gsd skill dirs', () => {
// Create a non-GSD skill dir
const otherDir = path.join(skillsDir, 'my-custom-skill');
fs.mkdirSync(otherDir, { recursive: true });
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
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');
});
});