Files
msd-core/tests/helpers.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

167 lines
6.1 KiB
JavaScript

/**
* GSD Tools Test Helpers
*/
const { execSync, execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const TOOLS_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
/**
* Run gsd-tools command.
*
* @param {string|string[]} args - Command string (shell-interpreted) or array
* of arguments (shell-bypassed via execFileSync, safe for JSON and dollar signs).
* @param {string} cwd - Working directory.
* @param {object} [env] - Optional env overrides merged on top of process.env.
* Pass { HOME: cwd } to sandbox ~/.gsd/ lookups in tests that assert concrete
* config values that could be overridden by a developer's defaults.json.
*/
function runGsdTools(args, cwd = process.cwd(), env = {}) {
try {
let result;
const childEnv = { ...process.env, ...TEST_ENV_BASE, ...env };
if (Array.isArray(args)) {
result = execFileSync(process.execPath, [TOOLS_PATH, ...args], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: childEnv,
});
} else {
// Split shell-style string into argv, stripping surrounding quotes, so we
// can invoke execFileSync with process.execPath instead of relying on
// `node` being on PATH (it isn't in Claude Code shell sessions).
// Apply shell-style quote removal: strip surrounding quotes from quoted
// sequences anywhere in a token (handles both "foo bar" and --"foo bar").
const argv = (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
.map(t => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
result = execFileSync(process.execPath, [TOOLS_PATH, ...argv], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: childEnv,
});
}
return { success: true, output: result.trim() };
} catch (err) {
return {
success: false,
output: err.stdout?.toString().trim() || '',
error: err.stderr?.toString().trim() || err.message,
};
}
}
// Create a bare temp directory (no .planning/ structure)
function createTempDir(prefix = 'gsd-test-') {
return fs.mkdtempSync(path.join(require('os').tmpdir(), prefix));
}
// Create temp directory structure
function createTempProject(prefix = 'gsd-test-') {
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), prefix));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
return tmpDir;
}
// Create temp directory with initialized git repo and at least one commit
function createTempGitProject(prefix = 'gsd-test-') {
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), prefix));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
execSync('git config commit.gpgsign false', { cwd: tmpDir, stdio: 'pipe' });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Project\n\nTest project.\n'
);
execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' });
execSync('git commit -m "initial commit"', { cwd: tmpDir, stdio: 'pipe' });
return tmpDir;
}
function cleanup(tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
/**
* Parse a Markdown frontmatter block into a flat key→value map.
*
* Handles the YAML scalar forms emitted by the install converters:
* key: "json-encoded value" → JSON.parse
* key: 'value with ''escape'' → strip quotes, unescape ''
* key: bare value → trimmed string
*
* Multi-line and block scalars are out of scope — every converter in
* `bin/install.js` emits single-line scalars only. Throws if the content
* has no closed `---` block so a regression in the emitter shape fails
* loudly rather than silently returning {}.
*
* Tests use this helper instead of `result.includes('key: value')` to
* follow the project's "tests parse, never grep" convention.
*
* @param {string} content - Full file content beginning with `---`.
* @returns {Record<string, string>} Map of frontmatter keys to decoded values.
*/
function parseFrontmatter(content) {
if (!content.startsWith('---')) {
throw new Error(`parseFrontmatter: content must start with '---', got: ${content.slice(0, 40)}`);
}
// CRLF tolerance: a Windows-authored file split on `\n` would leave a
// trailing `\r` on every line, making `lines[i] === '---'` fail to
// recognize delimiters. Same goes for whitespace-padded delimiter lines.
// Normalize via a CRLF-aware split + trimmed comparison.
const lines = content.split(/\r?\n/);
let openIdx = -1;
let closeIdx = -1;
for (let i = 0; i < lines.length; i += 1) {
if (lines[i].trim() === '---') {
if (openIdx === -1) openIdx = i;
else { closeIdx = i; break; }
}
}
if (openIdx === -1 || closeIdx === -1) {
throw new Error('parseFrontmatter: no closed --- block');
}
const fields = {};
for (const line of lines.slice(openIdx + 1, closeIdx)) {
const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (!match) continue; // skip block-list items, blank lines, comments
const [, key, rawValue] = match;
const value = rawValue.trim();
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
fields[key] = JSON.parse(value);
} else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
fields[key] = value.slice(1, -1).replace(/''/g, "'");
} else {
fields[key] = value;
}
}
return fields;
}
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, TOOLS_PATH };