computePathPrefix returned a Windows-style path (with backslashes from path.join) into markdown @-references. Workflow file content on Windows ended up with mixed separators, breaking substring checks in install/install-runtime-artifacts tests on windows-latest CI only. Normalize resolvedTarget and homeDir to forward slashes inside computePathPrefix. The prefix is always substituted into markdown body text, which uses POSIX paths universally. Idempotent on POSIX. Also normalizes the two test assertions to forward-slash form so they pass on Windows. Adds a regression test for backslash-style input. Documents DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT + RULESET.CONTENT-PATH-NORMALIZATION in CONTEXT.md so this anti-pattern stops recurring.
382 lines
16 KiB
JavaScript
382 lines
16 KiB
JavaScript
'use strict';
|
|
/**
|
|
* Tests for ADR-1508 Phase 2: rewrite engine relocation to runtime-artifact-conversion.
|
|
* Issue #1511 — verifies the deep public seam signatures and behavior.
|
|
*
|
|
* Tests are behavioral (no source-grep). All filesystem operations use tmp dirs.
|
|
*/
|
|
|
|
const { describe, test, before } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
let conversion;
|
|
before(() => {
|
|
process.env['GSD_TEST_MODE'] = '1';
|
|
conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// _computePathPrefix unit tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('_computePathPrefix', () => {
|
|
test('global under home → $HOME/... form', () => {
|
|
const prefix = conversion._computePathPrefix({
|
|
isGlobal: true,
|
|
isOpencode: false,
|
|
isWindowsHost: false,
|
|
resolvedTarget: '/home/u/.cursor',
|
|
homeDir: '/home/u',
|
|
});
|
|
assert.equal(prefix, '$HOME/.cursor/');
|
|
});
|
|
|
|
test('non-global → resolvedTarget/ form', () => {
|
|
const prefix = conversion._computePathPrefix({
|
|
isGlobal: false,
|
|
isOpencode: false,
|
|
isWindowsHost: false,
|
|
resolvedTarget: '/project/.cursor',
|
|
homeDir: '/home/u',
|
|
});
|
|
assert.equal(prefix, '/project/.cursor/');
|
|
});
|
|
|
|
test('global opencode skips $HOME shorthand', () => {
|
|
// OpenCode uses ~/.config/opencode which breaks $HOME shorthand in content
|
|
const prefix = conversion._computePathPrefix({
|
|
isGlobal: true,
|
|
isOpencode: true,
|
|
isWindowsHost: false,
|
|
resolvedTarget: '/home/u/.config/opencode',
|
|
homeDir: '/home/u',
|
|
});
|
|
assert.equal(prefix, '/home/u/.config/opencode/');
|
|
});
|
|
|
|
test('global target outside home → resolvedTarget/ form', () => {
|
|
const prefix = conversion._computePathPrefix({
|
|
isGlobal: true,
|
|
isOpencode: false,
|
|
isWindowsHost: false,
|
|
resolvedTarget: '/opt/custom-cursor',
|
|
homeDir: '/home/u',
|
|
});
|
|
assert.equal(prefix, '/opt/custom-cursor/');
|
|
});
|
|
|
|
test('isWindowsHost tripwire — Windows paths collapse to $HOME/ same as POSIX (no-op today)', () => {
|
|
// Documents CURRENT behavior: isWindowsHost is accepted but not branched on.
|
|
// Both win32=true and win32=false return '$HOME/.cursor/' for a home-relative target.
|
|
// If a future Windows-specific branch is added, this tripwire fails and forces
|
|
// an explicit decision about what to return on Windows.
|
|
const withWindows = conversion._computePathPrefix({
|
|
isGlobal: true,
|
|
isOpencode: false,
|
|
isWindowsHost: true,
|
|
resolvedTarget: 'C:/Users/matte/.cursor',
|
|
homeDir: 'C:/Users/matte',
|
|
});
|
|
const withoutWindows = conversion._computePathPrefix({
|
|
isGlobal: true,
|
|
isOpencode: false,
|
|
isWindowsHost: false,
|
|
resolvedTarget: 'C:/Users/matte/.cursor',
|
|
homeDir: 'C:/Users/matte',
|
|
});
|
|
assert.equal(withWindows, '$HOME/.cursor/');
|
|
assert.strictEqual(withWindows, withoutWindows);
|
|
});
|
|
|
|
test('backslash-style resolvedTarget is normalized to forward slashes (#1615 regression)', () => {
|
|
// path.join on Windows produces backslashes; the returned prefix is
|
|
// substituted into markdown @-references which must use POSIX paths.
|
|
// Without normalization the backslashes leak into workflow file content
|
|
// and break substring checks on Windows CI.
|
|
const prefix = conversion._computePathPrefix({
|
|
isGlobal: false,
|
|
isOpencode: false,
|
|
isWindowsHost: true,
|
|
resolvedTarget: 'C:\\Users\\runner\\AppData\\Local\\Temp\\gsd-1615-windsurf',
|
|
homeDir: 'C:\\Users\\runner',
|
|
});
|
|
assert.strictEqual(prefix, 'C:/Users/runner/AppData/Local/Temp/gsd-1615-windsurf/');
|
|
assert.ok(!prefix.includes('\\'), `prefix must not contain backslashes: ${prefix}`);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// _applyRuntimeRewrites with injected attribution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('_applyRuntimeRewrites — attribution injection', () => {
|
|
const PREFIX = '$HOME/.cursor/';
|
|
|
|
test('attribution=null removes Co-Authored-By line', () => {
|
|
const content = '# Hello\n\nSome text\n\nCo-Authored-By: Claude\n';
|
|
const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, null);
|
|
assert.ok(!result.includes('Co-Authored-By:'), 'Co-Authored-By should be removed');
|
|
});
|
|
|
|
test('attribution=undefined leaves Co-Authored-By unchanged', () => {
|
|
const content = '# Hello\n\nCo-Authored-By: Claude\n';
|
|
const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, undefined);
|
|
assert.ok(result.includes('Co-Authored-By: Claude'), 'Co-Authored-By should be preserved when attribution=undefined');
|
|
});
|
|
|
|
test('attribution=string replaces Co-Authored-By value', () => {
|
|
const content = '# Hello\n\nCo-Authored-By: OldName\n';
|
|
const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, 'NewName <new@example.com>');
|
|
assert.ok(result.includes('Co-Authored-By: NewName <new@example.com>'), 'Co-Authored-By should be replaced');
|
|
});
|
|
|
|
test('cursor runtime replaces ~/.claude/ paths', () => {
|
|
const content = 'See ~/.claude/skills/ for more info\n';
|
|
const result = conversion._applyRuntimeRewrites(content, 'cursor', '/home/u/.cursor/', false, undefined);
|
|
assert.ok(result.includes('/home/u/.cursor/skills/'), 'cursor should replace ~/.claude/ with pathPrefix');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// rewriteStagedSkillBodies — behavioral filesystem test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('rewriteStagedSkillBodies', () => {
|
|
test('rewrites .md files in-place for cursor runtime', () => {
|
|
const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-'));
|
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-'));
|
|
try {
|
|
// Create a skill dir with a SKILL.md referencing ~/.claude/skills/foo
|
|
// NOTE: the rewrite engine handles path replacement and attribution only.
|
|
// Bash→Shell conversion is done by the stage-1 skill converter, not the engine.
|
|
const skillDir = path.join(stagedDir, 'gsd-test-skill');
|
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
const content = '# Test\n\nSee ~/.claude/skills/foo\n\nAlso ~/.cursor/skills/bar\n';
|
|
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
|
|
|
// Call with injected homedir + platform for determinism
|
|
conversion.rewriteStagedSkillBodies(stagedDir, {
|
|
runtime: 'cursor',
|
|
configDir,
|
|
scope: 'global',
|
|
homedir: () => '/home/u',
|
|
platform: 'linux',
|
|
});
|
|
|
|
const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8');
|
|
// cursor rewrites ~/.claude/ → pathPrefix
|
|
// configDir is a tmpdir, not under /home/u, so prefix = resolvedTarget + '/'
|
|
// Mirror the engine's backslash→slash normalization so the assertion holds on Windows.
|
|
const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
|
|
assert.ok(result.includes(`${resolvedTarget}/skills/foo`), `Should replace ~/.claude/skills/ with ${resolvedTarget}/skills/`);
|
|
// cursor also rewrites ~/.cursor/ → pathPrefix
|
|
assert.ok(result.includes(`${resolvedTarget}/skills/bar`), `Should replace ~/.cursor/skills/ with ${resolvedTarget}/skills/`);
|
|
} finally {
|
|
cleanup(stagedDir);
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('with injected homedir: global under home uses $HOME prefix', () => {
|
|
// Real absolute path so Windows path.resolve does not re-root a POSIX literal onto a drive.
|
|
// The dir need not exist — the engine only string-processes it.
|
|
const HOME = path.resolve(os.tmpdir(), 'gsd-1511-fake-home');
|
|
const configDir = path.join(HOME, '.cursor');
|
|
const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-'));
|
|
try {
|
|
const skillDir = path.join(stagedDir, 'gsd-help');
|
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Use ~/.claude/skills/ here\n');
|
|
|
|
conversion.rewriteStagedSkillBodies(stagedDir, {
|
|
runtime: 'cursor',
|
|
configDir,
|
|
scope: 'global',
|
|
homedir: () => HOME,
|
|
platform: process.platform,
|
|
});
|
|
|
|
const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8');
|
|
assert.ok(result.includes('$HOME/.cursor/skills/'), 'Should use $HOME shorthand when configDir is under homedir');
|
|
} finally {
|
|
cleanup(stagedDir);
|
|
}
|
|
});
|
|
|
|
test('non-existent stagedDir is a no-op', () => {
|
|
assert.doesNotThrow(() => {
|
|
conversion.rewriteStagedSkillBodies('/nonexistent/dir', {
|
|
runtime: 'cursor',
|
|
configDir: '/tmp/fake',
|
|
scope: 'global',
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// rewriteStagedCommandBodies — returns temp dir, does not mutate source
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('rewriteStagedCommandBodies', () => {
|
|
test('returns a temp dir (not the source dir) with rewritten content', () => {
|
|
const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-cmd-'));
|
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-'));
|
|
let tempDir;
|
|
try {
|
|
// NOTE: rewrite engine handles path replacement + attribution, NOT tool renames.
|
|
fs.writeFileSync(path.join(stagedDir, 'help.md'), '# Help\n\nSee ~/.claude/skills/\n\nSee ~/.cursor/skills/\n');
|
|
|
|
tempDir = conversion.rewriteStagedCommandBodies(stagedDir, {
|
|
runtime: 'cursor',
|
|
configDir,
|
|
scope: 'global',
|
|
homedir: () => '/home/u',
|
|
platform: 'linux',
|
|
});
|
|
|
|
assert.notEqual(tempDir, stagedDir, 'must return a different dir, never the source');
|
|
assert.ok(fs.existsSync(tempDir), 'returned tempDir should exist');
|
|
|
|
const result = fs.readFileSync(path.join(tempDir, 'help.md'), 'utf8');
|
|
// Source dir should be unchanged
|
|
const source = fs.readFileSync(path.join(stagedDir, 'help.md'), 'utf8');
|
|
assert.ok(source.includes('~/.claude/skills/'), 'source file must not be mutated');
|
|
// configDir is /tmp/... (not under /home/u), so prefix = resolvedTarget + '/'
|
|
const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
|
|
assert.ok(result.includes(`${resolvedTarget}/skills/`), 'output should have cursor path rewrite applied');
|
|
// ~/.cursor/ also rewrites to prefix
|
|
assert.ok(!result.includes('~/.cursor/'), 'output should have ~/.cursor/ replaced too');
|
|
} finally {
|
|
cleanup(stagedDir);
|
|
cleanup(configDir);
|
|
if (tempDir && tempDir !== stagedDir) {
|
|
cleanup(tempDir);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('non-existent stagedDir returns stagedDir unchanged (safe)', () => {
|
|
const result = conversion.rewriteStagedCommandBodies('/nonexistent/dir', {
|
|
runtime: 'cursor',
|
|
configDir: '/tmp/fake',
|
|
scope: 'global',
|
|
});
|
|
assert.equal(result, '/nonexistent/dir', 'should return input path unchanged for missing dir');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir
|
|
// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir cleanup', () => {
|
|
test('rmSync is called on the tempDir when readFileSync throws (deterministic monkeypatch)', () => {
|
|
// Asserting the injected error propagates proves the throw happens AFTER the tempDir is
|
|
// created (the function creates tempDir, then reads .md), so the catch's rmSync cleanup
|
|
// is genuinely exercised — deterministic on every platform/uid.
|
|
const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-'));
|
|
fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n');
|
|
|
|
const before = new Set(
|
|
fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-'))
|
|
);
|
|
|
|
const origReadFileSync = fs.readFileSync;
|
|
let leaked = [];
|
|
try {
|
|
fs.readFileSync = () => { throw new Error('injected read failure'); };
|
|
|
|
assert.throws(
|
|
() => conversion.applyRuntimeContentRewritesForCommandsInPlace(stagedDir, 'cursor', '/tmp/x/', false),
|
|
/injected read failure/,
|
|
);
|
|
|
|
// Restore before any further fs use so the snapshot read is trustworthy.
|
|
fs.readFileSync = origReadFileSync;
|
|
|
|
const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-'));
|
|
leaked = after.filter(n => !before.has(n));
|
|
assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`);
|
|
} finally {
|
|
// Idempotent restore — guard against early-throw paths above.
|
|
fs.readFileSync = origReadFileSync;
|
|
// Clean up the staged dir created for this test.
|
|
cleanup(stagedDir);
|
|
// Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean.
|
|
for (const n of leaked) {
|
|
cleanup(path.join(os.tmpdir(), n));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Guard: runtime-artifact-layout no longer exports getInstallExports
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('layout module no longer exports getInstallExports', () => {
|
|
test('getInstallExports is not on the layout module export', () => {
|
|
process.env['GSD_TEST_MODE'] = '1';
|
|
const layout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
|
assert.equal(
|
|
typeof layout.getInstallExports,
|
|
'undefined',
|
|
'getInstallExports should have been removed from runtime-artifact-layout exports (ADR-1508 Phase 2)',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DEFECT.GENERATIVE-FIX: single-owner reference-identity guard (#1511)
|
|
// Proves install.js binds to the conversion module's implementation, not a
|
|
// duplicate local copy. If these fail, a duplicate body was re-introduced.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('single-owner reference-identity guard (ADR-1508 / #1511 Phase 2)', () => {
|
|
let install;
|
|
let conversionCjs;
|
|
before(() => {
|
|
process.env['GSD_TEST_MODE'] = '1';
|
|
install = require('../bin/install.js');
|
|
conversionCjs = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
|
});
|
|
|
|
test('install.computePathPrefix === conversion._computePathPrefix (single implementation)', () => {
|
|
assert.strictEqual(
|
|
install.computePathPrefix,
|
|
conversionCjs._computePathPrefix,
|
|
'install.js must bind computePathPrefix from conversion (not a duplicate body)',
|
|
);
|
|
});
|
|
|
|
test('install.applyRuntimeContentRewritesInPlace === conversion.applyRuntimeContentRewritesInPlace (single walk loop)', () => {
|
|
assert.strictEqual(
|
|
install.applyRuntimeContentRewritesInPlace,
|
|
conversionCjs.applyRuntimeContentRewritesInPlace,
|
|
'install.js must bind applyRuntimeContentRewritesInPlace from conversion (not a duplicate walk loop)',
|
|
);
|
|
});
|
|
|
|
test('install.applyRuntimeContentRewritesForCommandsInPlace === conversion.applyRuntimeContentRewritesForCommandsInPlace (single copy+rewrite loop)', () => {
|
|
assert.strictEqual(
|
|
install.applyRuntimeContentRewritesForCommandsInPlace,
|
|
conversionCjs.applyRuntimeContentRewritesForCommandsInPlace,
|
|
'install.js must bind applyRuntimeContentRewritesForCommandsInPlace from conversion (not a duplicate copy+rewrite loop)',
|
|
);
|
|
});
|
|
|
|
test('install._applyRuntimeRewrites === conversion._applyRuntimeRewrites (single switch engine)', () => {
|
|
assert.strictEqual(
|
|
install._applyRuntimeRewrites,
|
|
conversionCjs._applyRuntimeRewrites,
|
|
'install.js must bind _applyRuntimeRewrites from conversion (not a local shim)',
|
|
);
|
|
});
|
|
});
|