* test(#3118): failing-first coverage for the dead injectables and the shell projection Adds the counter-tests Wave 4 closes against, before any fix: - antigravityWatermark had zero test references. The four existing tests that look like watermark coverage hand the fallback a literal mark and never call the producer, so nothing pinned whether a real run's mark is correct. Covers all six branches plus the non-object cache classes. - Pins the fail-open: a transcript read that throws reports lines:0, indistinguishable from a genuinely empty transcript, and the consumer then replays a previous run's review as this run's. - Pins the export-line escaping across the repair, persist and win32 bash lanes, including the parity assertion that they must not diverge. - sliceCurrentPositionSection: empty-vs-absent, fenced heading, second occurrence, H3, CRLF. - Proves deps.progressProvider is inert by supplying a throwing stub to all ten transition intents. Verification through the remote runner only. Refs #3118 * fix(#3118): distinguish an unreadable transcript from an empty one antigravityWatermark's final read can throw on a transcript that indisputably exists. It returned lines:0, which is the same value a genuinely empty transcript produces, so the caller could not tell the two apart. antigravityTranscriptFallback derives its skip from that count. A mark of {convId:'c1', lines:0} for a conversation that pre-dates the run makes it skip nothing and return the last PLANNER_RESPONSE in a transcript written before this run started — a previous review presented as this one's, which is exactly what the function's own 'never stale' docstring promises cannot happen. The unreadable case now sets unreadable:true and the fallback declines for a same-conv-id unreadable mark. An absent or empty transcript is untouched: those genuinely have zero prior lines. * fix(#3118): escape the export line for the file it lands in, not the echo Three lanes emit export PATH="<dir>:$PATH". repair escaped it with escapePosixDoubleQuoted; persist and the win32 Git Bash lane escaped it with escapeSingleQuotedShellLiteral instead. The single-quoting is correct for the echo, so nothing runs when the user pastes the command. But the bytes appended to ~/.bashrc are the export line itself, and inside double quotes in an rc file a $(...) or a backtick in the directory name is command substitution that runs on every new shell. Those characters are legal in a path on both POSIX and Windows, so the path was reachable. projectPathExportLine is now the single source of that line and escapes for its final rc-file context; each lane still applies its own transport escaping on top. fish keeps the single-quote escaper — its value really does stay single-quoted. The cmd.exe lane interpolated into a cmd double-quoted string with no cmd-level escaping, so a quote closed the region and &cmd& ran. A quote is reserved on Windows and cannot appear in a real path, so there is no correct command to suggest: the win32 lanes now fail closed for one. Metacharacter-free paths render byte-identically on every lane. * fix(#3118): drop a stray carriage return and a deps field nobody reads locateCurrentPosition subtracted a fixed one byte to exclude the newline before the next heading, which assumes LF. On a CRLF document the slice kept an unpaired trailing carriage return. It now walks back over the newline and over a preceding carriage return if there is one. StateTransitionDeps also required a progressProvider that 33 sites supplied and no site ever called. A required field nothing reads widens the module's interface without changing its implementation, which is the shape epic #3051 cites as its reason for refusing blanket injection. Removed along with the ProgressRecord alias that existed only as its return type; state-document.cts's unrelated interface of the same name is untouched. * fix(#3118): stop an empty span duplicating bytes, and name the empty results Three findings from the isolated review pass. locateCurrentPosition could return end < start when the section was empty and the next heading followed with no blank line between. Every mutator splices with slice(0,start) + body + slice(end), so an inverted span duplicated the region between them — a blank line silently inserted into STATE.md on every transition, two bytes on CRLF. The span is now clamped, and an empty section is a zero-length span, which is what it always meant. The win32 fail-closed path left the installer printing 'Add it with one of:' with nothing under it. An empty shellActions folded two different facts together, so projectPathActionProjection now carries a frozen PATH_ACTION_REASON and the installer branches on it. Two empty results with different causes staying distinguishable is the subject of the epic this belongs to. fish_add_path parses a leading dash as an option, so a directory named -v printed 'No paths to add' instead of being added. Verified against fish 4.8.1: the end-of-options separator fixes it. Replaces the console-prose test the second fix first arrived with — a regex over captured stdout is what CONTRIBUTING prohibits, and the typed reason is the surface it asks for instead. * fix(#3118): escape TOML control characters, and stop a test name overstating Five findings from the two review axes. escapeTomlDoubleQuotedString escaped only backslash and quote. TOML basic strings also require U+0000-U+0008, U+000A-U+001F and U+007F to be escaped, so a value carrying a raw newline or NUL wrote a config.toml no parser accepts — rejecting the whole file, not just that value. Four of its call sites write real config. Tab stays raw; the grammar exempts it. The byte-identity test claimed every lane was unchanged for an ordinary path, which is false: fish now takes the end-of-options separator on every path, not only hostile ones. Renamed, and the one intended delta now has its own named test instead of hiding inside a claim that read as broader than it was. Also: exact-equality assertions in place of substring checks that could pass on a subtly wrong escape, newline and null-byte cases for all five quoting primitives, and a temp dir registered with t.after so it is removed when an assertion fails. * docs(#3118): add the changeset fragments * fix(#3118): degrade instead of throwing on a null conversation cache A cache file whose whole content is the literal null — what a truncated or zeroed write leaves behind — made both antigravityWatermark and antigravityTranscriptFallback throw. JSON.parse('null') succeeds, so the try/catch wrapping the parse never fired, and resolveConvId then called hasOwnProperty on null. Both functions advertise the opposite; the existing test next to them is named 'a missing cache or transcript degrades to empty, never throws'. Parsing successfully is not the same fact as the payload being usable, and a guard that only wraps the parse cannot tell them apart. resolveConvId is now total for any non-object input, so one guard covers both callers. Caught by the null case in this wave's own cache matrix. * test(#3118): correct a stale fish expectation and a parity comparison The pre-existing 'POSIX persist mode escapes single quotes' test pinned fish_add_path without the end-of-options separator this wave adds, so it asserted behavior that is no longer correct. A repo-wide scan found one such hardcoded expectation; every other site derives its expectation from the projection. The new parity test compared the token from a POSIX path against the win32 lane, which posix-normalizes its input first — two different inputs, so the tokens differed for a reason that had nothing to do with the parity it claims to check. It now derives the win32 expectation from the same input the lane receives. * docs(#3118): reword a comment the injection scanner reads as an instruction The scanner pattern act\s+as\s+(?:a|an|the)\s+ carries no word boundary, so 'the same fact as the payload' matched on the tail of 'fact'. Reworded per the documented remedy for this collision. The missing boundary is a scanner defect rather than a prose problem — any contributor writing 'fact as the' trips it — but the pattern is gate plumbing, which the sibling epic owns, so it is surfaced rather than changed here. * chore(#3118): backfill changeset pr number to 3124 * chore(#3118): backfill changeset pr number to 3124 * fix(#2784): make the negation scan single-pass and index it correctly Three defects in the negation suppression added by #3127, all in one block, none of which had a test. The pair scan was verbs.some(nouns.some(...)) with a slice and a split per pair, so it grew cubically with clause length: 1.1ms before that PR and 8462ms after, on 800 verb+noun pairs in one clause. api-coverage's property test generates documents large enough to reach the runner's 600s file cap, which is why it hangs as 'fail 0, cancelled 1' rather than failing an assertion. Every (verb, noun) window is a subset of the single widest one, so one scan of that window answers the same question in a linear pass. Verified equivalent against the old predicate over 20,000 generated clauses. Both checks also subtracted clause.start from offsets that collectTerm- Matches already returns clause-local. The first clause on a line has start 0 so it worked there and nowhere else: later clauses went negative, and slice reads a negative index from the end, so suppression silently examined unrelated text. The comment claimed 'without any API integration' was suppressed. It is not — the qualifier sits outside the two-word lookback and the noun precedes the verb. Widening the window would trade a false positive that costs one declaration line for a false negative that slips a real integration past a blocking gate, so the behavior stands and the comment now says so. Pinned by a test. The qualifier sets were also rebuilt for every line of every document.
2023 lines
85 KiB
JavaScript
2023 lines
85 KiB
JavaScript
'use strict';
|
||
|
||
const { describe, test, beforeEach, afterEach, mock } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('node:path');
|
||
const fs = require('node:fs');
|
||
const childProcess = require('node:child_process');
|
||
|
||
const {
|
||
execGit,
|
||
execNpm,
|
||
execTool,
|
||
probeTty,
|
||
normalizeContent,
|
||
platformWriteSync,
|
||
platformReadSync,
|
||
platformEnsureDir,
|
||
dispatchGsdCommand,
|
||
resolveGsdToolsPath,
|
||
projectPathActionProjection,
|
||
projectPathExportLine,
|
||
posixNormalize,
|
||
PATH_ACTION_REASON,
|
||
renderShellActionLines,
|
||
formatManagedHookScriptToken,
|
||
escapeTomlDoubleQuotedString,
|
||
escapePowerShellSingleQuoted,
|
||
escapePosixDoubleQuoted,
|
||
escapeSingleQuotedShellLiteral,
|
||
retryRenameSync,
|
||
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
|
||
|
||
const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs');
|
||
|
||
// ─── execGit ─────────────────────────────────────────────────────────────────
|
||
|
||
describe('execGit', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => { tmpDir = createTempGitProject(); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('returns { exitCode, stdout, stderr } shape', () => {
|
||
const result = execGit(['--version']);
|
||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'exitCode'), 'missing exitCode');
|
||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stdout'), 'missing stdout');
|
||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stderr'), 'missing stderr');
|
||
});
|
||
|
||
test('exitCode 0 for successful command', () => {
|
||
const result = execGit(['--version']);
|
||
assert.strictEqual(result.exitCode, 0);
|
||
});
|
||
|
||
test('stdout contains version string for --version', () => {
|
||
const result = execGit(['--version']);
|
||
assert.strictEqual(typeof result.stdout, 'string');
|
||
assert.ok(result.stdout.length > 0, 'stdout should not be empty for git --version');
|
||
});
|
||
|
||
test('exitCode non-zero for failing command — does not throw', () => {
|
||
const result = execGit(['status', '--porcelain'], { cwd: '/tmp/definitely-not-a-git-repo-8675309' });
|
||
assert.notStrictEqual(result.exitCode, 0);
|
||
});
|
||
|
||
test('respects cwd option', () => {
|
||
const result = execGit(['status', '--porcelain'], { cwd: tmpDir });
|
||
assert.strictEqual(result.exitCode, 0);
|
||
});
|
||
});
|
||
|
||
// ─── execNpm ─────────────────────────────────────────────────────────────────
|
||
|
||
describe('execNpm', () => {
|
||
test('returns { exitCode, stdout, stderr } shape', () => {
|
||
const result = execNpm(['--version']);
|
||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'exitCode'), 'missing exitCode');
|
||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stdout'), 'missing stdout');
|
||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stderr'), 'missing stderr');
|
||
});
|
||
|
||
test('exitCode 0 for npm --version', () => {
|
||
const result = execNpm(['--version']);
|
||
assert.strictEqual(result.exitCode, 0);
|
||
});
|
||
|
||
test('stdout is non-empty for npm --version', () => {
|
||
const result = execNpm(['--version']);
|
||
assert.ok(result.stdout.trim().length > 0);
|
||
});
|
||
});
|
||
|
||
// ─── execTool ────────────────────────────────────────────────────────────────
|
||
|
||
describe('execTool', () => {
|
||
test('returns { exitCode, stdout, stderr } shape for known program', () => {
|
||
const result = execTool('node', ['--version']);
|
||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'exitCode'), 'missing exitCode');
|
||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stdout'), 'missing stdout');
|
||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stderr'), 'missing stderr');
|
||
});
|
||
|
||
test('exitCode 0 for node --version', () => {
|
||
const result = execTool('node', ['--version']);
|
||
assert.strictEqual(result.exitCode, 0);
|
||
});
|
||
|
||
test('exitCode 127 and no throw when program does not exist', () => {
|
||
const result = execTool('definitely-not-a-real-program-8675309', []);
|
||
assert.strictEqual(result.exitCode, 127);
|
||
assert.strictEqual(result.stdout, '');
|
||
assert.strictEqual(typeof result.stderr, 'string');
|
||
});
|
||
});
|
||
|
||
// ─── dispatchGsdCommand (#2102 Stage 2 — subprocess-shim dispatch to gsd-tools.cjs) ──
|
||
//
|
||
// The command-routing hub (`createHub()`) has no fully-populated factory
|
||
// anywhere in the tree — every caller builds a single-family hub — so the
|
||
// only dispatch path covering the FULL family/subcommand surface is the
|
||
// gsd-tools.cjs CLI itself. This is the shared helper pi/gsd.cjs and the
|
||
// companion MCP server both dispatch through.
|
||
|
||
describe('dispatchGsdCommand', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => { tmpDir = createTempDir(); });
|
||
afterEach(() => { cleanup(tmpDir); mock.restoreAll(); });
|
||
|
||
test('resolveGsdToolsPath resolves to the real gsd-tools.cjs on disk', () => {
|
||
const toolsPath = resolveGsdToolsPath();
|
||
assert.ok(fs.existsSync(toolsPath), `expected gsd-tools.cjs to exist at ${toolsPath}`);
|
||
assert.equal(path.basename(toolsPath), 'gsd-tools.cjs');
|
||
});
|
||
|
||
test('a valid read-only family/subcommand dispatches for real and returns ok:true + non-empty stdout', () => {
|
||
const result = dispatchGsdCommand({ family: 'progress', subcommand: 'json', cwd: tmpDir });
|
||
assert.equal(result.ok, true, `expected ok:true, got: ${JSON.stringify(result)}`);
|
||
assert.equal(typeof result.stdout, 'string');
|
||
assert.ok(result.stdout.length > 0, 'stdout must be non-empty');
|
||
const parsed = JSON.parse(result.stdout);
|
||
assert.equal(typeof parsed.percent, 'number', 'the real progress command ran (proves the engine was reached)');
|
||
assert.equal(result.code, 0);
|
||
assert.equal(result.timedOut, false);
|
||
});
|
||
|
||
test('an unknown family returns ok:false without throwing', () => {
|
||
assert.doesNotThrow(() => {
|
||
const result = dispatchGsdCommand({ family: 'no-such-family-8675309', cwd: tmpDir });
|
||
assert.equal(result.ok, false);
|
||
assert.notEqual(result.code, 0);
|
||
assert.equal(typeof result.stderr, 'string');
|
||
assert.ok(result.stderr.length > 0);
|
||
// --json-errors gives a structured, parseable error envelope.
|
||
const parsedErr = JSON.parse(result.stderr);
|
||
assert.equal(parsedErr.ok, false);
|
||
});
|
||
});
|
||
|
||
test('a missing/bogus gsd-tools.cjs path degrades to ok:false without throwing', () => {
|
||
assert.doesNotThrow(() => {
|
||
const result = dispatchGsdCommand({
|
||
family: 'progress',
|
||
cwd: tmpDir,
|
||
gsdToolsPath: path.join(tmpDir, 'definitely-not-a-real-gsd-tools-8675309.cjs'),
|
||
});
|
||
assert.equal(result.ok, false);
|
||
assert.equal(result.timedOut, false);
|
||
});
|
||
});
|
||
|
||
test('a missing/empty "family" is rejected locally without spawning a subprocess', () => {
|
||
const result = dispatchGsdCommand({ cwd: tmpDir });
|
||
assert.equal(result.ok, false);
|
||
assert.equal(result.code, null);
|
||
assert.match(result.stderr, /requires a non-empty string "family"/);
|
||
});
|
||
|
||
test('a wall-clock timeout is reported via timedOut:true, ok:false — never throws', () => {
|
||
assert.doesNotThrow(() => {
|
||
const result = dispatchGsdCommand({ family: 'progress', subcommand: 'json', cwd: tmpDir, timeout: 1 });
|
||
assert.equal(result.ok, false);
|
||
assert.equal(result.timedOut, true);
|
||
});
|
||
});
|
||
|
||
// #3050 item 4: this site (dispatchGsdCommand's `timedOut` derivation) now
|
||
// routes through the shared isSpawnTimeout predicate, which drops the
|
||
// `signal === 'SIGTERM'` requirement — a Windows-shaped timeout (no signal,
|
||
// only error.code === 'ETIMEDOUT') must still be detected. The real-timeout
|
||
// test above only exercises whatever shape THIS OS's spawnSync happens to
|
||
// produce (SIGTERM on POSIX); mocking spawnSync proves the Windows shape too.
|
||
test('Windows-shaped timeout (no signal, error.code ETIMEDOUT) is still reported as timedOut:true (#3050)', () => {
|
||
mock.method(childProcess, 'spawnSync', () => ({
|
||
status: null,
|
||
stdout: '',
|
||
stderr: '',
|
||
signal: null,
|
||
error: Object.assign(new Error('spawnSync ETIMEDOUT'), { code: 'ETIMEDOUT' }),
|
||
}));
|
||
|
||
const result = dispatchGsdCommand({ family: 'progress', subcommand: 'json', cwd: tmpDir });
|
||
assert.equal(result.ok, false);
|
||
assert.equal(result.timedOut, true);
|
||
});
|
||
});
|
||
|
||
// ─── probeTty ────────────────────────────────────────────────────────────────
|
||
|
||
describe('probeTty', () => {
|
||
test('returns string or null — never throws', () => {
|
||
const result = probeTty();
|
||
assert.ok(result === null || typeof result === 'string', `expected string|null, got ${typeof result}`);
|
||
});
|
||
|
||
test('returns null when platform is win32', () => {
|
||
const result = probeTty({ platform: 'win32' });
|
||
assert.strictEqual(result, null);
|
||
});
|
||
});
|
||
|
||
// ─── normalizeContent ────────────────────────────────────────────────────────
|
||
|
||
describe('normalizeContent', () => {
|
||
test('returns { content, encoding } shape', () => {
|
||
const result = normalizeContent('file.md', 'hello\n');
|
||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'content'), 'missing content');
|
||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'encoding'), 'missing encoding');
|
||
});
|
||
|
||
test('normalizes CRLF to LF for .md files', () => {
|
||
const result = normalizeContent('file.md', 'line1\r\nline2\r\n');
|
||
assert.ok(!result.content.includes('\r\n'), 'CRLF should be normalized to LF');
|
||
});
|
||
|
||
test('normalizes CRLF to LF for non-.md files', () => {
|
||
const result = normalizeContent('file.json', '{"a":1}\r\n');
|
||
assert.ok(!result.content.includes('\r\n'), 'CRLF should be normalized to LF');
|
||
});
|
||
|
||
test('enforces single trailing newline for .md files', () => {
|
||
const result = normalizeContent('file.md', 'hello');
|
||
assert.ok(result.content.endsWith('\n'), 'should end with newline');
|
||
assert.ok(!result.content.endsWith('\n\n'), 'should not end with double newline');
|
||
});
|
||
|
||
test('enforces single trailing newline for non-.md files', () => {
|
||
const result = normalizeContent('file.txt', 'hello');
|
||
assert.ok(result.content.endsWith('\n'));
|
||
assert.ok(!result.content.endsWith('\n\n'));
|
||
});
|
||
|
||
test('applies full markdownlint normalization for .md files — blank line before heading', () => {
|
||
const input = [
|
||
'# Title',
|
||
'paragraph',
|
||
'## Section',
|
||
].join('\n');
|
||
const result = normalizeContent('file.md', input);
|
||
assert.ok(result.content.includes('\n\n## Section'), 'MD022: blank line before heading');
|
||
});
|
||
|
||
test('does NOT apply markdown structural rules to non-.md files', () => {
|
||
const input = 'paragraph\n## Not a heading in json\n';
|
||
const result = normalizeContent('file.json', input);
|
||
assert.strictEqual(result.content, input);
|
||
});
|
||
|
||
test('encoding defaults to utf-8', () => {
|
||
const result = normalizeContent('file.md', 'hello\n');
|
||
assert.strictEqual(result.encoding, 'utf-8');
|
||
});
|
||
});
|
||
|
||
// ─── platformWriteSync ───────────────────────────────────────────────────────
|
||
|
||
describe('platformWriteSync', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => { tmpDir = createTempDir(); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('written file exists and is a regular file', () => {
|
||
const filePath = path.join(tmpDir, 'output.md');
|
||
platformWriteSync(filePath, '# Hello\n');
|
||
assert.ok(fs.statSync(filePath).isFile());
|
||
});
|
||
|
||
test('written file has non-zero size', () => {
|
||
const filePath = path.join(tmpDir, 'output.md');
|
||
platformWriteSync(filePath, '# Hello\n');
|
||
assert.ok(fs.statSync(filePath).size > 0);
|
||
});
|
||
|
||
test('creates parent directory if absent', () => {
|
||
const filePath = path.join(tmpDir, 'nested', 'deep', 'output.md');
|
||
platformWriteSync(filePath, '# Hello\n');
|
||
assert.ok(fs.statSync(filePath).isFile());
|
||
});
|
||
|
||
test('mtime advances on re-write', (_t) => {
|
||
const filePath = path.join(tmpDir, 'output.md');
|
||
platformWriteSync(filePath, '# First\n');
|
||
const mtimeBefore = fs.statSync(filePath).mtimeMs;
|
||
// Small delay to ensure mtime differs
|
||
const start = Date.now();
|
||
while (Date.now() - start < 10) { /* busy wait */ }
|
||
platformWriteSync(filePath, '# Second\n');
|
||
const mtimeAfter = fs.statSync(filePath).mtimeMs;
|
||
assert.ok(mtimeAfter >= mtimeBefore, 'mtime should advance or stay same on re-write');
|
||
});
|
||
|
||
test('no temp file left on disk after successful write', () => {
|
||
const filePath = path.join(tmpDir, 'output.md');
|
||
platformWriteSync(filePath, '# Hello\n');
|
||
const tmpFiles = fs.readdirSync(tmpDir).filter(f => f.includes('.tmp.'));
|
||
assert.strictEqual(tmpFiles.length, 0, 'no temp files should remain');
|
||
});
|
||
});
|
||
|
||
// ─── platformReadSync ────────────────────────────────────────────────────────
|
||
|
||
describe('platformReadSync', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => { tmpDir = createTempDir(); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('returns null for missing file when required is false (default)', () => {
|
||
const result = platformReadSync(path.join(tmpDir, 'nonexistent.md'));
|
||
assert.strictEqual(result, null);
|
||
});
|
||
|
||
test('throws for missing file when required is true', () => {
|
||
assert.throws(
|
||
() => platformReadSync(path.join(tmpDir, 'nonexistent.md'), { required: true }),
|
||
/ENOENT/,
|
||
);
|
||
});
|
||
|
||
test('returns string content for existing file', () => {
|
||
const filePath = path.join(tmpDir, 'existing.md');
|
||
fs.writeFileSync(filePath, '# Hello\n', 'utf-8');
|
||
const result = platformReadSync(filePath);
|
||
assert.strictEqual(typeof result, 'string');
|
||
assert.ok(result.length > 0);
|
||
});
|
||
});
|
||
|
||
// ─── platformEnsureDir ───────────────────────────────────────────────────────
|
||
|
||
describe('platformEnsureDir', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => { tmpDir = createTempDir(); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('creates directory if absent', () => {
|
||
const dirPath = path.join(tmpDir, 'new', 'nested', 'dir');
|
||
platformEnsureDir(dirPath);
|
||
assert.ok(fs.statSync(dirPath).isDirectory());
|
||
});
|
||
|
||
test('no error when directory already exists — idempotent', () => {
|
||
const dirPath = path.join(tmpDir, 'existing');
|
||
fs.mkdirSync(dirPath);
|
||
assert.doesNotThrow(() => platformEnsureDir(dirPath));
|
||
});
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-1906-hook-relative-paths.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-1906-hook-relative-paths (consolidation epic #1969 B3 #1972)", () => {
|
||
/**
|
||
* Regression tests for bug #1906
|
||
*
|
||
* Local installs must anchor hook command paths to $CLAUDE_PROJECT_DIR so
|
||
* hooks resolve correctly regardless of the shell's current working directory.
|
||
*
|
||
* The original bug: local install hook commands used bare relative paths like
|
||
* `node .claude/hooks/gsd-context-monitor.js`. Claude Code persists the bash
|
||
* tool's cwd between calls, so a single `cd subdir && …` early in a session
|
||
* permanently broke every hook for the rest of that session.
|
||
*
|
||
* The fix prefixes all local hook commands with "$CLAUDE_PROJECT_DIR"/ so
|
||
* path resolution is always anchored to the project root.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { describe, test, before } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('path');
|
||
|
||
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
|
||
const { projectLocalHookPrefix, projectShellCommandText } = projection;
|
||
|
||
describe('bug #1906: local hook commands use $CLAUDE_PROJECT_DIR', () => {
|
||
before(() => {
|
||
assert.equal(typeof projectLocalHookPrefix, 'function');
|
||
assert.equal(typeof projectShellCommandText, 'function');
|
||
});
|
||
|
||
test('non-Gemini runtimes get $CLAUDE_PROJECT_DIR anchored local prefix', () => {
|
||
const prefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
|
||
assert.equal(prefix, '"$CLAUDE_PROJECT_DIR"/.claude');
|
||
});
|
||
|
||
test('local command projection for non-Gemini keeps $CLAUDE_PROJECT_DIR anchor', () => {
|
||
const prefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
|
||
const command = projectShellCommandText({
|
||
runnerToken: '"/usr/local/bin/node"',
|
||
argTokens: [`${prefix}/hooks/gsd-context-monitor.js`],
|
||
runtime: 'claude',
|
||
platform: 'linux',
|
||
});
|
||
assert.equal(
|
||
command,
|
||
'"/usr/local/bin/node" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js',
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-2557-gemini-local-hook-paths.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-2557-gemini-local-hook-paths (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
|
||
/**
|
||
* Bug #2557: Gemini CLI local hook commands must NOT use $CLAUDE_PROJECT_DIR.
|
||
*
|
||
* $CLAUDE_PROJECT_DIR is a Claude Code-specific env variable. Gemini CLI did
|
||
* not set it. On Windows, Gemini's own variable-substitution + path-join logic
|
||
* produced a doubled path like `D:\Projects\GSD\'D:\Projects\GSD'`, causing
|
||
* every local project hook to fail at SessionStart.
|
||
*
|
||
* Fix: localPrefix was made runtime-conditional. Gemini/Antigravity used bare
|
||
* dirName (relative path) since they always run project hooks with the project
|
||
* dir as cwd. Claude Code and others still use "$CLAUDE_PROJECT_DIR"/ (#1906).
|
||
*
|
||
* #1928: Google sunset Gemini CLI (2026-06-18) and the `gemini` runtime was
|
||
* removed from GSD entirely.
|
||
*
|
||
* #2096: `projectLocalHookPrefix` no longer special-cases `antigravity` by
|
||
* name — it branches on the caller-supplied `hookPathStyle` (sourced from
|
||
* the runtime's `hostBehaviors.hookPathStyle` descriptor field). An
|
||
* unrecognized runtime string like the former `'gemini'`, or any runtime
|
||
* that doesn't declare `hookPathStyle: 'raw'`, falls through to the default
|
||
* $CLAUDE_PROJECT_DIR-anchored prefix.
|
||
*/
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('node:path');
|
||
|
||
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
|
||
const { projectLocalHookPrefix, projectShellCommandText } = projection;
|
||
|
||
describe('bug #2557: Antigravity local hooks use relative paths (not $CLAUDE_PROJECT_DIR); gemini runtime removed (#1928)', () => {
|
||
test('Antigravity local prefix is bare dirName', () => {
|
||
// #2096: hookPathStyle is now the caller-supplied descriptor value
|
||
// (bin/install.js resolves it from hostBehaviors.hookPathStyle); the
|
||
// function itself no longer knows the runtime name 'antigravity'.
|
||
assert.equal(
|
||
projectLocalHookPrefix({ runtime: 'antigravity', dirName: '.agents', hookPathStyle: 'raw' }),
|
||
'.agents',
|
||
);
|
||
});
|
||
|
||
test('non-Antigravity local prefix remains $CLAUDE_PROJECT_DIR anchored', () => {
|
||
assert.equal(
|
||
projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }),
|
||
'"$CLAUDE_PROJECT_DIR"/.claude',
|
||
);
|
||
});
|
||
|
||
test('an unrecognized runtime string (e.g. the former "gemini") falls through to $CLAUDE_PROJECT_DIR anchoring (#1928)', () => {
|
||
const prefix = projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' });
|
||
assert.equal(prefix, '"$CLAUDE_PROJECT_DIR"/.gemini');
|
||
const command = projectShellCommandText({
|
||
runnerToken: '"/usr/local/bin/node"',
|
||
argTokens: [`${prefix}/hooks/gsd-check-update.js`],
|
||
runtime: 'gemini',
|
||
platform: 'linux',
|
||
});
|
||
assert.ok(
|
||
command.includes('$CLAUDE_PROJECT_DIR'),
|
||
'an unrecognized runtime must use the default anchored prefix, not the retired Gemini-only bare-path carve-out',
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3413-shell-command-projection.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3413-shell-command-projection (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('node:path');
|
||
|
||
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
|
||
const install = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||
|
||
const {
|
||
hookCommandNeedsPowerShellCallOperator,
|
||
formatHookCommandForRuntime,
|
||
isManagedHookBasename,
|
||
isManagedHookCommand,
|
||
projectLocalHookPrefix,
|
||
projectLegacySettingsHookCommand,
|
||
projectPortableHookBaseDir,
|
||
} = projection;
|
||
const { buildHookCommand, rewriteLegacyManagedNodeHookCommands } = install;
|
||
|
||
describe('bug #3413: Shell Command Projection Module uses runtime-aware hook policy', () => {
|
||
test('#1928: no current runtime needs the PowerShell call operator (seam inert after gemini removal)', () => {
|
||
// Gemini CLI was the only runtime that needed `& ` on Windows; it was
|
||
// removed (#1928, Google sunset 2026-06-18). Antigravity — the Gemini-backend
|
||
// successor — never matched the old check, so it stays prefix-free. Lock the
|
||
// now-inert contract so a future re-enable is a deliberate change.
|
||
assert.equal(
|
||
hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'antigravity' }),
|
||
false,
|
||
);
|
||
assert.equal(
|
||
formatHookCommandForRuntime('"C:/node.exe" "C:/hook.js"', { platform: 'win32', runtime: 'antigravity' }),
|
||
'"C:/node.exe" "C:/hook.js"',
|
||
);
|
||
});
|
||
|
||
test('Claude on Windows stays shell-neutral', () => {
|
||
assert.equal(
|
||
hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude' }),
|
||
false,
|
||
);
|
||
assert.equal(
|
||
formatHookCommandForRuntime('"C:/node.exe" "C:/hook.js"', { platform: 'win32', runtime: 'claude' }),
|
||
'"C:/node.exe" "C:/hook.js"',
|
||
);
|
||
});
|
||
|
||
test('runtime omitted stays conservative (no PowerShell prefix)', () => {
|
||
assert.equal(
|
||
formatHookCommandForRuntime('"C:/node.exe" "C:/hook.js"', { platform: 'win32' }),
|
||
'"C:/node.exe" "C:/hook.js"',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('bug #2236: hookShell parameter routes PowerShell call operator', () => {
|
||
test('hookShell=powershell on Windows returns true for any runtime', () => {
|
||
assert.equal(
|
||
hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude', hookShell: 'powershell' }),
|
||
true,
|
||
'PowerShell hook shell must trigger the call operator regardless of runtime',
|
||
);
|
||
assert.equal(
|
||
hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'antigravity', hookShell: 'powershell' }),
|
||
true,
|
||
);
|
||
});
|
||
|
||
test('hookShell=bash (default) stays false — Git-Bash form preserved', () => {
|
||
assert.equal(
|
||
hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude', hookShell: 'bash' }),
|
||
false,
|
||
);
|
||
assert.equal(
|
||
hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude' }),
|
||
false,
|
||
);
|
||
});
|
||
|
||
test('formatHookCommandForRuntime prepends & when hookShell=powershell', () => {
|
||
const cmd = '"C:/Program Files/nodejs/node.exe" "C:/Users/me/.claude/hooks/gsd-context-monitor.js"';
|
||
assert.equal(
|
||
formatHookCommandForRuntime(cmd, { platform: 'win32', runtime: 'claude', hookShell: 'powershell' }),
|
||
`& ${cmd}`,
|
||
);
|
||
});
|
||
|
||
test('formatHookCommandForRuntime omits & when hookShell unset (backward compat)', () => {
|
||
const cmd = '"C:/Program Files/nodejs/node.exe" "C:/Users/me/.claude/hooks/gsd-context-monitor.js"';
|
||
assert.equal(
|
||
formatHookCommandForRuntime(cmd, { platform: 'win32', runtime: 'claude' }),
|
||
cmd,
|
||
);
|
||
});
|
||
|
||
test('buildHookCommand emits & prefix when hookShell=powershell', () => {
|
||
const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', {
|
||
platform: 'win32',
|
||
runtime: 'claude',
|
||
hookShell: 'powershell',
|
||
});
|
||
assert.ok(cmd, 'buildHookCommand should return a command');
|
||
assert.ok(cmd.startsWith('& '), `PowerShell hook command must start with '& ': ${cmd}`);
|
||
});
|
||
|
||
test('buildHookCommand omits & prefix when hookShell=bash (regression lock)', () => {
|
||
const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', {
|
||
platform: 'win32',
|
||
runtime: 'claude',
|
||
hookShell: 'bash',
|
||
});
|
||
assert.ok(cmd, 'buildHookCommand should return a command');
|
||
assert.equal(cmd.startsWith('& '), false, `Git-Bash hook command must NOT start with '& ': ${cmd}`);
|
||
});
|
||
});
|
||
|
||
describe('bug #3413: installer hook surfaces consume runtime-aware projection', () => {
|
||
test('buildHookCommand emits shell-neutral Claude hook command on Windows', () => {
|
||
const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', {
|
||
platform: 'win32',
|
||
runtime: 'claude',
|
||
});
|
||
assert.equal(cmd.startsWith('& '), false, `Claude hook command must not use PowerShell prefix: ${cmd}`);
|
||
});
|
||
|
||
test('rewriteLegacyManagedNodeHookCommands removes stale PowerShell prefix for Claude on Windows', () => {
|
||
const settings = {
|
||
hooks: {
|
||
SessionStart: [{
|
||
hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"' }],
|
||
}],
|
||
},
|
||
};
|
||
const changed = rewriteLegacyManagedNodeHookCommands(settings, '"/usr/local/bin/node"', {
|
||
platform: 'win32',
|
||
runtime: 'claude',
|
||
});
|
||
assert.equal(changed, true);
|
||
assert.equal(
|
||
settings.hooks.SessionStart[0].hooks[0].command,
|
||
'"/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('bug #3439: shell projection module owns managed-hook policy and legacy rewrite projection', () => {
|
||
test('isManagedHookBasename is surface-aware', () => {
|
||
assert.equal(isManagedHookBasename('/x/hooks/gsd-check-update.js', { surface: 'settings-json' }), true);
|
||
assert.equal(isManagedHookBasename('/x/hooks/gsd-statusline.js', { surface: 'settings-json' }), true);
|
||
assert.equal(isManagedHookBasename('/x/hooks/gsd-statusline.js', { surface: 'codex-toml' }), false);
|
||
assert.equal(isManagedHookBasename('/x/hooks/custom-hook.js', { surface: 'settings-json' }), false);
|
||
});
|
||
|
||
test('projectLegacySettingsHookCommand preserves non-Windows script token shape', () => {
|
||
const command = projectLegacySettingsHookCommand({
|
||
absoluteRunner: '"/usr/local/bin/node"',
|
||
scriptPath: '/x/hooks/gsd-statusline.js',
|
||
scriptToken: "'/x/hooks/gsd-statusline.js'",
|
||
platform: 'linux',
|
||
runtime: 'claude',
|
||
});
|
||
assert.equal(command, `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`);
|
||
});
|
||
|
||
test('projectLegacySettingsHookCommand normalizes Windows managed paths and runtime wrapper policy', () => {
|
||
// #1928: gemini runtime removed — the PowerShell call-operator seam is now
|
||
// inert for every runtime (including the former 'gemini' string and its
|
||
// Gemini-backend successor 'antigravity'). No `& ` prefix is ever added.
|
||
const command = projectLegacySettingsHookCommand({
|
||
absoluteRunner: '"C:/nvm4w/nodejs/node.exe"',
|
||
scriptPath: 'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js',
|
||
scriptToken: "'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js'",
|
||
platform: 'win32',
|
||
runtime: 'antigravity',
|
||
});
|
||
assert.equal(command, '"C:/nvm4w/nodejs/node.exe" "C:/Users/me/.gemini/hooks/gsd-prompt-guard.js"');
|
||
});
|
||
|
||
test('projectLocalHookPrefix centralizes runtime-specific project-dir interpolation policy', () => {
|
||
// #2096: 'raw' hookPathStyle (descriptor-driven) is what produces the
|
||
// bare-dirName behavior now — the function no longer branches on the
|
||
// 'antigravity' runtime name itself.
|
||
assert.equal(
|
||
projectLocalHookPrefix({ runtime: 'antigravity', dirName: '.agents', hookPathStyle: 'raw' }),
|
||
'.agents',
|
||
);
|
||
assert.equal(
|
||
projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }),
|
||
'"$CLAUDE_PROJECT_DIR"/.claude',
|
||
);
|
||
// #1928: gemini runtime removed — an unrecognized runtime string falls
|
||
// through to the default $CLAUDE_PROJECT_DIR-anchored prefix.
|
||
assert.equal(
|
||
projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' }),
|
||
'"$CLAUDE_PROJECT_DIR"/.gemini',
|
||
);
|
||
});
|
||
|
||
test('projectPortableHookBaseDir centralizes $HOME interpolation policy', () => {
|
||
assert.equal(
|
||
projectPortableHookBaseDir({
|
||
configDir: '/Users/me/.claude',
|
||
homeDir: '/Users/me',
|
||
}),
|
||
'$HOME/.claude',
|
||
);
|
||
assert.equal(
|
||
projectPortableHookBaseDir({
|
||
configDir: 'C:\\Users\\me\\.claude',
|
||
homeDir: 'C:\\Users\\me',
|
||
}),
|
||
'$HOME/.claude',
|
||
);
|
||
assert.equal(
|
||
projectPortableHookBaseDir({
|
||
configDir: '/opt/custom/.claude',
|
||
homeDir: '/Users/me',
|
||
}),
|
||
'/opt/custom/.claude',
|
||
);
|
||
});
|
||
|
||
test('isManagedHookCommand classifies managed settings hooks and leaves user commands untouched', () => {
|
||
assert.equal(
|
||
isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.claude/hooks/gsd-statusline.js"', {
|
||
surface: 'settings-json',
|
||
}),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
isManagedHookCommand('"C:/Program Files/Git/bin/bash.exe" "C:/Users/me/.claude/hooks/gsd-session-state.sh"', {
|
||
surface: 'settings-json',
|
||
}),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
isManagedHookCommand('bash /Users/me/.claude/hooks/custom-lint.sh', {
|
||
surface: 'settings-json',
|
||
}),
|
||
false,
|
||
);
|
||
});
|
||
|
||
test('isManagedHookCommand supports codex surfaces and optional legacy alias matching', () => {
|
||
const command = '"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-check-update.js"';
|
||
assert.equal(
|
||
isManagedHookCommand(command, {
|
||
surface: 'codex-toml',
|
||
}),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"', {
|
||
surface: 'codex-toml',
|
||
}),
|
||
false,
|
||
);
|
||
assert.equal(
|
||
isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"', {
|
||
surface: 'codex-toml',
|
||
includeLegacyAliases: true,
|
||
}),
|
||
true,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('#1693 regression: Windows legacy-node rewrite must not double-quote a "$CLAUDE_PROJECT_DIR"-anchored local hook path', () => {
|
||
const winRunner = '"C:/Program Files/nodejs/node.exe"';
|
||
|
||
// WHY: a local-install hook path already carries a `"$CLAUDE_PROJECT_DIR"`
|
||
// anchored prefix (only the variable quoted, rest bare). On Windows the legacy
|
||
// rewrite previously JSON.stringify'd the whole token, yielding
|
||
// `"\"$CLAUDE_PROJECT_DIR\"/..."`. node then received an argument starting with
|
||
// a literal `"`, treated it as relative, and died with MODULE_NOT_FOUND —
|
||
// breaking every node managed hook at once (self-locking deadlock).
|
||
test('projectLegacySettingsHookCommand emits the anchored path verbatim, not re-quoted', () => {
|
||
const anchored = '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js';
|
||
const command = projectLegacySettingsHookCommand({
|
||
absoluteRunner: winRunner,
|
||
scriptPath: anchored,
|
||
scriptToken: anchored,
|
||
platform: 'win32',
|
||
runtime: 'claude',
|
||
});
|
||
assert.equal(
|
||
command,
|
||
'"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js',
|
||
);
|
||
assert.ok(!command.includes('\\"'), 'must not contain escaped double-quotes');
|
||
});
|
||
|
||
// WHY: the fix must be surgical — a BARE absolute Windows path (no anchored
|
||
// prefix) can contain spaces ("Program Files") and still REQUIRES quoting.
|
||
test('projectLegacySettingsHookCommand still quotes a bare absolute Windows path', () => {
|
||
const abs = 'C:/Program Files App/.claude/hooks/gsd-context-monitor.js';
|
||
const command = projectLegacySettingsHookCommand({
|
||
absoluteRunner: winRunner,
|
||
scriptPath: abs,
|
||
scriptToken: JSON.stringify(abs),
|
||
platform: 'win32',
|
||
runtime: 'claude',
|
||
});
|
||
assert.equal(
|
||
command,
|
||
'"C:/Program Files/nodejs/node.exe" "C:/Program Files App/.claude/hooks/gsd-context-monitor.js"',
|
||
);
|
||
});
|
||
|
||
// WHY: the anchored short-circuit is scoped to win32. On POSIX the rewrite
|
||
// already preserved the caller's original `scriptToken` and never had the
|
||
// double-quote bug, so that behavior must be left byte-identical. scriptPath
|
||
// is anchored but scriptToken is a DISTINCT single-quoted value: if the
|
||
// win32 gate were removed, the anchored short-circuit would emit scriptPath
|
||
// and this assertion would fail — that is what pins the gate.
|
||
test('projectLegacySettingsHookCommand preserves the original scriptToken for anchored paths on POSIX', () => {
|
||
const command = projectLegacySettingsHookCommand({
|
||
absoluteRunner: '"/usr/local/bin/node"',
|
||
scriptPath: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-statusline.js',
|
||
scriptToken: "'/x/hooks/gsd-statusline.js'",
|
||
platform: 'linux',
|
||
runtime: 'claude',
|
||
});
|
||
assert.equal(command, `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`);
|
||
});
|
||
|
||
// WHY: end-to-end through the installer rewrite — the actual #2979 path that
|
||
// ran during the user's 1.5.0 -> 1.6.0 local update. Managed node hooks get
|
||
// the absolute runner + clean anchored path; a non-node-prefixed managed .sh
|
||
// hook (already correct) is left untouched.
|
||
test('rewriteLegacyManagedNodeHookCommands produces clean anchored node commands on Windows', () => {
|
||
const settings = {
|
||
hooks: {
|
||
PostToolUse: [
|
||
{
|
||
hooks: [
|
||
{ command: 'node "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
};
|
||
const changed = rewriteLegacyManagedNodeHookCommands(settings, winRunner, {
|
||
platform: 'win32',
|
||
runtime: 'claude',
|
||
});
|
||
assert.equal(changed, true);
|
||
const rewritten = settings.hooks.PostToolUse[0].hooks[0].command;
|
||
assert.equal(
|
||
rewritten,
|
||
'"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js',
|
||
);
|
||
assert.ok(!rewritten.includes('\\"'), 'rewritten command must not contain escaped double-quotes');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3441-path-action-projection.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3441-path-action-projection (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('node:path');
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
|
||
const projection = require(path.join(
|
||
__dirname,
|
||
'..',
|
||
'gsd-core',
|
||
'bin',
|
||
'lib',
|
||
'shell-command-projection.cjs',
|
||
));
|
||
const install = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||
const { withIsolatedProcessState, cleanup } = require('./helpers.cjs');
|
||
|
||
function createTempHome() {
|
||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-home-3441-'));
|
||
}
|
||
|
||
|
||
describe('bug #3441: PATH guidance is projected from typed shell action IR', () => {
|
||
test('projection module exports PATH action projection helper', () => {
|
||
assert.equal(typeof projection.projectPathActionProjection, 'function');
|
||
});
|
||
|
||
// (formatSdkPathDiagnostic removed with the gsd-sdk shim, #191 — the PATH
|
||
// action projection it wrapped is still covered by the tests below.)
|
||
|
||
test('persistent PATH export guidance is projected via the same seam', () => {
|
||
const posix = projection.projectPathActionProjection({
|
||
mode: 'persist',
|
||
targetDir: '/tmp/with quote',
|
||
platform: 'linux',
|
||
});
|
||
assert.ok(Array.isArray(posix.shellActions));
|
||
assert.equal(posix.shellActions.length, 3);
|
||
assert.equal(posix.shellActions[0].label, 'zsh');
|
||
assert.equal(posix.shellActions[1].label, 'bash');
|
||
assert.equal(posix.shellActions[2].label, 'fish');
|
||
assert.ok(posix.shellActions[0].command.includes('~/.zshrc'));
|
||
assert.ok(posix.shellActions[1].command.includes('~/.bashrc'));
|
||
// #323: fish gets a fish-native fish_add_path suggestion, not `export`.
|
||
assert.ok(posix.shellActions[2].command.startsWith('fish_add_path '));
|
||
assert.ok(!posix.shellActions[2].command.includes('export'));
|
||
});
|
||
|
||
// #323 (ported from the closed #721): the fish suggestion is POSIX-only.
|
||
// On win32 the persist branch projects PowerShell / cmd.exe / Git Bash —
|
||
// no fish action — locking the POSIX-only contract.
|
||
test('no fish action is projected on win32', () => {
|
||
const win = projection.projectPathActionProjection({
|
||
mode: 'persist',
|
||
targetDir: 'C:\\Users\\me\\AppData\\npm',
|
||
platform: 'win32',
|
||
});
|
||
assert.ok(Array.isArray(win.shellActions));
|
||
assert.equal(
|
||
win.shellActions.some((a) => a.shell === 'fish' || a.label === 'fish'),
|
||
false,
|
||
'win32 persist projection must not include a fish action',
|
||
);
|
||
assert.deepEqual(
|
||
win.shellActions.map((a) => a.label),
|
||
['PowerShell', 'cmd.exe', 'Git Bash'],
|
||
);
|
||
});
|
||
|
||
test('POSIX repair mode escapes double-quoted shell metacharacters', () => {
|
||
const projected = projection.projectPathActionProjection({
|
||
mode: 'repair',
|
||
targetDir: '/tmp/qa\\"$HOME`tick',
|
||
platform: 'linux',
|
||
});
|
||
assert.equal(projected.shellActions.length, 1);
|
||
assert.equal(
|
||
projected.shellActions[0].command,
|
||
'export PATH="/tmp/qa\\\\\\"\\$HOME\\`tick:$PATH"',
|
||
);
|
||
});
|
||
|
||
test('POSIX persist mode escapes single quotes for rc-file echo commands', () => {
|
||
const projected = projection.projectPathActionProjection({
|
||
mode: 'persist',
|
||
targetDir: "/tmp/O'Neil/bin",
|
||
platform: 'linux',
|
||
});
|
||
assert.equal(projected.shellActions[0].command.includes("/tmp/O'\\''Neil/bin"), true);
|
||
assert.equal(projected.shellActions[1].command.includes("/tmp/O'\\''Neil/bin"), true);
|
||
// #323: fish entry single-quotes the dir with the same POSIX literal
|
||
// escaping (`'\''` is also a valid escaped quote in fish unquoted context).
|
||
// #3118: `--` is fish's end-of-options separator, added so a leading-dash
|
||
// directory name is not misparsed as a flag; every fish_add_path lane carries it now.
|
||
assert.equal(projected.shellActions[2].command, "fish_add_path -- '/tmp/O'\\''Neil/bin'");
|
||
});
|
||
|
||
test('maybeSuggestPathExport renders commands projected by path-action seam', () => {
|
||
const home = createTempHome();
|
||
try {
|
||
withIsolatedProcessState(() => {
|
||
const globalBin = path.join(home, '.npm-global', 'bin');
|
||
fs.mkdirSync(globalBin, { recursive: true });
|
||
fs.writeFileSync(path.join(home, '.zshrc'), 'export PATH="$HOME/.cargo/bin:$PATH"\n');
|
||
process.env.PATH = '';
|
||
|
||
const expected = projection.projectPathActionProjection({
|
||
mode: 'persist',
|
||
targetDir: globalBin,
|
||
platform: process.platform,
|
||
});
|
||
|
||
const logs = [];
|
||
const originalLog = console.log;
|
||
console.log = (...args) => logs.push(args.join(' '));
|
||
try {
|
||
install.maybeSuggestPathExport(globalBin, home);
|
||
} finally {
|
||
console.log = originalLog;
|
||
}
|
||
|
||
const joined = logs.join('\n');
|
||
for (const action of expected.shellActions) {
|
||
assert.ok(
|
||
joined.includes(action.command),
|
||
`expected installer output to include projected command: ${action.command}\nOutput:\n${joined}`,
|
||
);
|
||
}
|
||
});
|
||
} finally {
|
||
cleanup(home);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-580-local-sh-hook-bash-wrapper.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-580-local-sh-hook-bash-wrapper (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
|
||
/**
|
||
* Regression test for bug #580.
|
||
*
|
||
* LOCAL install under Claude Code on Windows: managed `.sh` hooks were emitted
|
||
* wrapped with an absolute `bash.exe` path via the `localShellCmd` arrow.
|
||
* Since Claude Code runs hook command strings INSIDE Git Bash, bash tries to
|
||
* exec bash → "cannot execute binary file".
|
||
*
|
||
* The GLOBAL path (`buildHookCommand`) already guarded win32+claude+.sh (#166).
|
||
* The LOCAL path (`localShellCmd`) did not. Fix: add `buildLocalShellHookCommand`
|
||
* and `shellHookOmitsBashRunner` to shell-command-projection.cjs, and use
|
||
* `buildLocalShellHookCommand` from install.js local-install path.
|
||
*/
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('node:path');
|
||
|
||
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
|
||
const { buildLocalShellHookCommand, shellHookOmitsBashRunner, projectLocalHookPrefix } = projection;
|
||
|
||
describe('bug #580: local .sh hooks on Claude/Windows must NOT wrap with bash.exe', () => {
|
||
test('local .sh hook on Claude/Windows omits the bash.exe wrapper (#580)', () => {
|
||
const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
|
||
const result = buildLocalShellHookCommand({
|
||
localPrefix,
|
||
hookFile: 'gsd-session-state.sh',
|
||
bashRunner: '"C:/Program Files/Git/bin/bash.exe"',
|
||
runtime: 'claude',
|
||
platform: 'win32',
|
||
});
|
||
assert.equal(result, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh');
|
||
assert.ok(!result.includes('bash.exe'), `result must not contain bash.exe, got: ${result}`);
|
||
});
|
||
|
||
test('local .sh hook on Claude/Windows still emits script path when bash.exe is unresolved', () => {
|
||
const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
|
||
const result = buildLocalShellHookCommand({
|
||
localPrefix,
|
||
hookFile: 'gsd-session-state.sh',
|
||
bashRunner: null,
|
||
runtime: 'claude',
|
||
platform: 'win32',
|
||
});
|
||
assert.equal(result, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh');
|
||
});
|
||
|
||
test('local .sh hook on POSIX keeps the bash runner', () => {
|
||
const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
|
||
const result = buildLocalShellHookCommand({
|
||
localPrefix,
|
||
hookFile: 'gsd-session-state.sh',
|
||
bashRunner: 'bash',
|
||
runtime: 'claude',
|
||
platform: 'linux',
|
||
});
|
||
assert.equal(result, 'bash "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh');
|
||
});
|
||
|
||
test('local .sh hook on Windows non-Claude runtime keeps the bash runner', () => {
|
||
const localPrefix = projectLocalHookPrefix({ runtime: 'codex', dirName: '.claude' });
|
||
const result = buildLocalShellHookCommand({
|
||
localPrefix,
|
||
hookFile: 'gsd-session-state.sh',
|
||
bashRunner: '"C:/Program Files/Git/bin/bash.exe"',
|
||
runtime: 'codex',
|
||
platform: 'win32',
|
||
});
|
||
assert.ok(result.includes('bash.exe'), `result must contain bash.exe, got: ${result}`);
|
||
assert.ok(result.startsWith('"C:/Program Files/Git/bin/bash.exe"'), `result must start with bash.exe token, got: ${result}`);
|
||
});
|
||
|
||
test('all four managed local .sh hooks drop the wrapper on Claude/Windows', () => {
|
||
const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
|
||
const hooks = [
|
||
'gsd-session-state.sh',
|
||
'gsd-validate-commit.sh',
|
||
'gsd-graphify-update.sh',
|
||
'gsd-phase-boundary.sh',
|
||
];
|
||
for (const f of hooks) {
|
||
const result = buildLocalShellHookCommand({
|
||
localPrefix,
|
||
hookFile: f,
|
||
bashRunner: '"C:/Program Files/Git/bin/bash.exe"',
|
||
runtime: 'claude',
|
||
platform: 'win32',
|
||
});
|
||
assert.equal(
|
||
result,
|
||
`"$CLAUDE_PROJECT_DIR"/.claude/hooks/${f}`,
|
||
`expected script-only path for ${f}, got: ${result}`,
|
||
);
|
||
assert.ok(!result.includes('bash.exe'), `result for ${f} must not contain bash.exe, got: ${result}`);
|
||
}
|
||
});
|
||
|
||
test('shellHookOmitsBashRunner truth table', () => {
|
||
// true only for win32 + claude + isShellHook:true
|
||
assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'claude', isShellHook: true }), true);
|
||
|
||
// false for win32 + claude + isShellHook:false
|
||
assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'claude', isShellHook: false }), false);
|
||
|
||
// false for win32 + codex + isShellHook:true
|
||
assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'codex', isShellHook: true }), false);
|
||
|
||
// false for linux + claude + isShellHook:true
|
||
assert.equal(shellHookOmitsBashRunner({ platform: 'linux', runtime: 'claude', isShellHook: true }), false);
|
||
|
||
// false for default args (no win32, no claude)
|
||
assert.equal(shellHookOmitsBashRunner(), false);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/feat-3595-fs-fault-injection-atomic-write.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:feat-3595-fs-fault-injection-atomic-write (consolidation epic #1969 B3 #1972)", () => {
|
||
/**
|
||
* Filesystem fault-injection coverage for the canonical atomic-write
|
||
* seam (#3595).
|
||
*
|
||
* `platformWriteSync` in `gsd-core/bin/lib/shell-command-projection.cjs`
|
||
* is the shared seam every config/state/generated-artifact writer in
|
||
* the CJS layer routes through. Its contract:
|
||
*
|
||
* 1. mkdirSync(dirname, { recursive: true }) — ensure parent exists.
|
||
* 2. writeFileSync(<tmpPath>, content) — write to a sibling tmpfile
|
||
* named `<filePath>.tmp.<pid>`.
|
||
* 3. renameSync(<tmpPath>, filePath) — atomic publish.
|
||
* 4. On any error in steps 2-3: unlinkSync(<tmpPath>) (best-effort)
|
||
* then writeFileSync(filePath, content) directly as a fallback.
|
||
*
|
||
* Per CONTRIBUTING.md §"QA Matrix Requirements / Filesystem writes and
|
||
* installers" the tests below use `mock.method()` against the real `fs`
|
||
* seam to drive each fault mode, restore mocks with `t.after()`, and
|
||
* assert on observable post-conditions (file existence, content,
|
||
* presence/absence of orphan tmp files, propagated error code) — not
|
||
* on prose.
|
||
*
|
||
* Pre-existing behavior gaps surfaced and PINNED (not fixed in this
|
||
* PR — #3595 is test coverage, fixes belong in separate issues):
|
||
*
|
||
* - The "fall back to direct write" branch silently swallows the
|
||
* ORIGINAL error from the tmp+rename path. If the fallback ALSO
|
||
* fails, the operator only sees the fallback's error — not the
|
||
* original cause. Tests document this.
|
||
* - On EACCES against the parent directory mkdir, the error
|
||
* escapes (no try/catch around mkdirSync). Pinned.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { test, mock } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const {
|
||
platformWriteSync,
|
||
platformEnsureDir,
|
||
} = require('../gsd-core/bin/lib/shell-command-projection.cjs');
|
||
|
||
/**
|
||
* Create a fresh real-fs scratch dir per test so no two faults share
|
||
* state. Returns the directory; caller must clean up.
|
||
*/
|
||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||
const mkScratch = (name) => createTempDir(`fs-fault-${name}-`);
|
||
|
||
/**
|
||
* Enumerate orphan tmp files left behind by platformWriteSync. The
|
||
* tmp shape is `<filename>.tmp.<pid>`; we match that pattern strictly
|
||
* so a test that happens to write a real `*.tmp.123` doesn't get a
|
||
* false positive.
|
||
*/
|
||
function orphanTmpFiles(dir) {
|
||
if (!fs.existsSync(dir)) return [];
|
||
return fs.readdirSync(dir).filter((n) => /\.tmp\.\d+$/.test(n));
|
||
}
|
||
|
||
// ─── Happy path baseline ────────────────────────────────────────────────────
|
||
|
||
test('platformWriteSync happy path writes content atomically (baseline for fault tests)', (t) => {
|
||
const dir = mkScratch('happy');
|
||
t.after(() => cleanup(dir));
|
||
const file = path.join(dir, 'config.json');
|
||
platformWriteSync(file, '{"k":"v"}\n');
|
||
assert.equal(fs.readFileSync(file, 'utf-8'), '{"k":"v"}\n');
|
||
assert.deepEqual(orphanTmpFiles(dir), [], 'happy path must leave no orphan tmp file');
|
||
});
|
||
|
||
// ─── Rename failure → falls back to direct write ────────────────────────────
|
||
|
||
test('platformWriteSync recovers when renameSync fails (EXDEV cross-device fallback)', (t) => {
|
||
const dir = mkScratch('exdev');
|
||
t.after(() => cleanup(dir));
|
||
const file = path.join(dir, 'config.json');
|
||
|
||
// Simulate rename failing once (e.g. cross-device move on a CI runner
|
||
// with overlayfs). The fallback path must write the content directly.
|
||
let renameCalls = 0;
|
||
const renameMock = mock.method(fs, 'renameSync', (_src, _dest) => {
|
||
renameCalls++;
|
||
const err = new Error('EXDEV: cross-device link not permitted');
|
||
err.code = 'EXDEV';
|
||
throw err;
|
||
});
|
||
t.after(() => renameMock.mock.restore());
|
||
|
||
platformWriteSync(file, 'fallback content\n');
|
||
|
||
// File exists and has the new content — fallback wrote it directly.
|
||
assert.equal(fs.readFileSync(file, 'utf-8'), 'fallback content\n');
|
||
assert.equal(renameCalls, 1, 'renameSync was called exactly once before falling back');
|
||
// The tmp file was unlinked by the fallback path's best-effort cleanup.
|
||
assert.deepEqual(orphanTmpFiles(dir), [], 'tmp file must be cleaned up after rename failure');
|
||
});
|
||
|
||
// ─── #1540: transient Windows lock (EPERM/EBUSY/EACCES) is RETRIED, never
|
||
// fallen back to a non-atomic truncating write ───────────────────
|
||
|
||
test('platformWriteSync retries a transient EPERM rename and publishes atomically (#1540)', (t) => {
|
||
const dir = mkScratch('eperm-transient');
|
||
t.after(() => cleanup(dir));
|
||
const file = path.join(dir, 'STATE.md');
|
||
|
||
// A reader briefly holds the target open → rename throws EPERM once, then clears.
|
||
let renameCalls = 0;
|
||
const originalRename = fs.renameSync;
|
||
const renameMock = mock.method(fs, 'renameSync', (src, dest) => {
|
||
renameCalls++;
|
||
if (renameCalls === 1) {
|
||
const err = new Error('EPERM: a reader holds the target open');
|
||
err.code = 'EPERM';
|
||
throw err;
|
||
}
|
||
return originalRename.call(fs, src, dest);
|
||
});
|
||
t.after(() => renameMock.mock.restore());
|
||
|
||
platformWriteSync(file, 'published\n');
|
||
|
||
assert.equal(renameCalls, 2, 'rename retried after a transient EPERM (not a single-shot non-atomic fallback)');
|
||
assert.equal(fs.statSync(file).isFile(), true);
|
||
assert.ok(fs.statSync(file).size > 0, 'target published, not truncated');
|
||
assert.deepEqual(orphanTmpFiles(dir), [], 'atomic publish leaves no tmp orphan');
|
||
});
|
||
|
||
test('platformWriteSync surfaces a PERSISTENT EPERM instead of truncating a concurrent reader (#1540)', (t) => {
|
||
const dir = mkScratch('eperm-persistent');
|
||
t.after(() => cleanup(dir));
|
||
const file = path.join(dir, 'STATE.md');
|
||
// A reader is mid-read on `file` with known content. The old blanket fallback
|
||
// would non-atomically writeFileSync over it — truncating the reader. The fix
|
||
// must surface the error and leave the existing file byte-for-byte intact.
|
||
fs.writeFileSync(file, 'OLD CONTENT A READER IS MID-READ ON\n');
|
||
const sizeBefore = fs.statSync(file).size;
|
||
|
||
let renameCalls = 0;
|
||
const renameMock = mock.method(fs, 'renameSync', () => {
|
||
renameCalls++;
|
||
const err = new Error('EPERM: reader holds the target open');
|
||
err.code = 'EPERM';
|
||
throw err;
|
||
});
|
||
t.after(() => renameMock.mock.restore());
|
||
|
||
let caught;
|
||
try {
|
||
platformWriteSync(file, 'NEW CONTENT\n');
|
||
} catch (err) {
|
||
caught = err;
|
||
}
|
||
|
||
assert.ok(caught, 'a persistent rename lock must surface as an error, not a silent truncating write');
|
||
assert.equal(caught.code, 'EPERM');
|
||
assert.equal(renameCalls, 3, 'rename retried up to the bounded limit before surfacing');
|
||
// Negative proof: the concurrent reader's file was NOT truncated/overwritten.
|
||
assert.equal(fs.statSync(file).size, sizeBefore, 'target left intact — no non-atomic write happened');
|
||
assert.equal(fs.readFileSync(file, 'utf-8'), 'OLD CONTENT A READER IS MID-READ ON\n');
|
||
assert.deepEqual(orphanTmpFiles(dir), [], 'tmp cleaned up after surfacing the error');
|
||
});
|
||
|
||
// ─── Tmp write failure → falls back to direct write ─────────────────────────
|
||
|
||
test('platformWriteSync falls back when initial tmp writeFileSync fails (ENOSPC)', (t) => {
|
||
const dir = mkScratch('enospc');
|
||
t.after(() => cleanup(dir));
|
||
const file = path.join(dir, 'config.json');
|
||
|
||
// Make the FIRST writeFileSync (to .tmp.<pid>) fail with ENOSPC. The
|
||
// SECOND writeFileSync (the fallback, direct to filePath) succeeds.
|
||
let writeCalls = 0;
|
||
const realWrite = fs.writeFileSync;
|
||
const writeMock = mock.method(fs, 'writeFileSync', function (target, data, opts) {
|
||
writeCalls++;
|
||
if (writeCalls === 1) {
|
||
// First call is to the tmp path.
|
||
assert.match(String(target), /\.tmp\.\d+$/, 'first write must be to the tmp path');
|
||
const err = new Error('ENOSPC: no space left on device');
|
||
err.code = 'ENOSPC';
|
||
throw err;
|
||
}
|
||
// Second call is the fallback to the real target.
|
||
assert.equal(target, file, 'fallback write must target the original file path');
|
||
return realWrite.call(fs, target, data, opts);
|
||
});
|
||
t.after(() => writeMock.mock.restore());
|
||
|
||
platformWriteSync(file, 'recovered\n');
|
||
|
||
assert.equal(writeCalls, 2, 'expected exactly 2 writeFileSync calls (tmp fail + fallback)');
|
||
assert.equal(fs.readFileSync(file, 'utf-8'), 'recovered\n');
|
||
// The fallback path tries unlinkSync on the tmp; the tmp never
|
||
// existed (its write failed), so unlink throws ENOENT and is
|
||
// swallowed by the inner catch. Either way: no orphan.
|
||
assert.deepEqual(orphanTmpFiles(dir), []);
|
||
});
|
||
|
||
// ─── Both attempts fail → error propagates (pinned current behavior) ────────
|
||
|
||
test('platformWriteSync propagates the FALLBACK error when both tmp and fallback writes fail', (t) => {
|
||
const dir = mkScratch('double-fail');
|
||
t.after(() => cleanup(dir));
|
||
const file = path.join(dir, 'config.json');
|
||
|
||
let writeCalls = 0;
|
||
const writeMock = mock.method(fs, 'writeFileSync', function () {
|
||
writeCalls++;
|
||
const err = new Error(
|
||
writeCalls === 1
|
||
? 'ENOSPC: original failure on tmp write'
|
||
: 'EACCES: permission denied on fallback write',
|
||
);
|
||
err.code = writeCalls === 1 ? 'ENOSPC' : 'EACCES';
|
||
throw err;
|
||
});
|
||
t.after(() => writeMock.mock.restore());
|
||
|
||
// The current implementation does NOT chain the original cause; the
|
||
// fallback's error is what surfaces. This test pins that behavior so a
|
||
// future "preserve original error in .cause" fix is a visible change
|
||
// (open follow-up).
|
||
let caught;
|
||
try {
|
||
platformWriteSync(file, 'wont-write\n');
|
||
} catch (err) {
|
||
caught = err;
|
||
}
|
||
assert.ok(caught, 'double-failure must throw');
|
||
assert.equal(caught.code, 'EACCES', 'currently the fallback error wins (open: should chain via .cause)');
|
||
// The original file was never created.
|
||
assert.equal(fs.existsSync(file), false);
|
||
// No orphan tmp left because the tmp write failed before any file was created.
|
||
assert.deepEqual(orphanTmpFiles(dir), []);
|
||
});
|
||
|
||
// ─── mkdirSync failure → escapes immediately (no try/catch upstream) ────────
|
||
|
||
test('platformWriteSync propagates mkdirSync failure unchanged (no swallowed parent-dir errors)', (t) => {
|
||
const dir = mkScratch('mkdir-fail');
|
||
t.after(() => cleanup(dir));
|
||
const file = path.join(dir, 'deep', 'nested', 'config.json');
|
||
|
||
const mkdirMock = mock.method(fs, 'mkdirSync', () => {
|
||
const err = new Error('EACCES: permission denied creating directory');
|
||
err.code = 'EACCES';
|
||
throw err;
|
||
});
|
||
t.after(() => mkdirMock.mock.restore());
|
||
|
||
let caught;
|
||
try {
|
||
platformWriteSync(file, 'never-reached\n');
|
||
} catch (err) {
|
||
caught = err;
|
||
}
|
||
assert.ok(caught, 'mkdir failure must propagate');
|
||
assert.equal(caught.code, 'EACCES', 'mkdir error code must be preserved');
|
||
// No partial write happened.
|
||
assert.equal(fs.existsSync(file), false);
|
||
});
|
||
|
||
// ─── Target path is a directory (writing OVER a dir is undefined) ──────────
|
||
|
||
test('platformWriteSync against a target path that is an existing directory fails cleanly', (t) => {
|
||
const dir = mkScratch('target-is-dir');
|
||
t.after(() => cleanup(dir));
|
||
const file = path.join(dir, 'collides');
|
||
// Pre-create the target AS a directory so the rename step would
|
||
// collide with a directory at the destination.
|
||
fs.mkdirSync(file);
|
||
|
||
let caught;
|
||
try {
|
||
platformWriteSync(file, 'shouldnt-overwrite-dir\n');
|
||
} catch (err) {
|
||
caught = err;
|
||
}
|
||
assert.ok(caught, 'writing over an existing directory must fail');
|
||
// We don't pin the exact code (Node varies: EISDIR on rename, EPERM on
|
||
// some platforms). We pin: a real error code (string starting with E)
|
||
// surfaces, AND the directory was not replaced by a file.
|
||
assert.equal(typeof caught.code, 'string');
|
||
assert.match(caught.code, /^E[A-Z]+$/, `expected an errno-style code, got ${caught.code}`);
|
||
assert.equal(fs.statSync(file).isDirectory(), true, 'pre-existing directory must remain a directory');
|
||
});
|
||
|
||
// ─── Path with spaces, unicode, newline characters ─────────────────────────
|
||
|
||
test('platformWriteSync handles paths with spaces, unicode, and newline characters', (t) => {
|
||
const dir = mkScratch('weird-path');
|
||
t.after(() => cleanup(dir));
|
||
const cases = [
|
||
'has spaces in name.json',
|
||
'unicode-日本語-name.json',
|
||
];
|
||
if (process.platform !== 'win32') {
|
||
// Tab (0x09) and newline (0x0A) in filenames are POSIX-valid but
|
||
// Windows-illegal (NTFS forbids control characters 0x00–0x1F). Append
|
||
// both only on POSIX so cross-platform CI stays green.
|
||
cases.push('with\ttab.json');
|
||
cases.push('with\nnewline.json');
|
||
}
|
||
|
||
for (const name of cases) {
|
||
const file = path.join(dir, name);
|
||
platformWriteSync(file, `payload for ${name}\n`);
|
||
assert.equal(
|
||
fs.readFileSync(file, 'utf-8'),
|
||
`payload for ${name}\n`,
|
||
`roundtrip failed for path "${JSON.stringify(name)}"`,
|
||
);
|
||
}
|
||
assert.deepEqual(orphanTmpFiles(dir), [], 'no tmp orphans across the corpus');
|
||
});
|
||
|
||
// ─── Orphan-tmp cleanup invariant ──────────────────────────────────────────
|
||
|
||
test('platformWriteSync never leaks a tmp file after a successful happy-path write', (t) => {
|
||
const dir = mkScratch('no-orphan-happy');
|
||
t.after(() => cleanup(dir));
|
||
for (let i = 0; i < 25; i++) {
|
||
platformWriteSync(path.join(dir, `f-${i}.json`), `{"i":${i}}\n`);
|
||
}
|
||
// 25 real files, 0 tmp orphans.
|
||
const entries = fs.readdirSync(dir);
|
||
const tmpCount = entries.filter((n) => /\.tmp\.\d+$/.test(n)).length;
|
||
const realCount = entries.length - tmpCount;
|
||
assert.equal(realCount, 25);
|
||
assert.equal(tmpCount, 0);
|
||
});
|
||
|
||
// ─── platformEnsureDir is idempotent and chains errors ─────────────────────
|
||
|
||
test('platformEnsureDir is idempotent on an existing directory', (t) => {
|
||
const dir = mkScratch('ensure-idem');
|
||
t.after(() => cleanup(dir));
|
||
const target = path.join(dir, 'a', 'b', 'c');
|
||
// First call creates; subsequent calls must not throw EEXIST.
|
||
platformEnsureDir(target);
|
||
assert.equal(fs.statSync(target).isDirectory(), true);
|
||
// Repeat.
|
||
platformEnsureDir(target);
|
||
platformEnsureDir(target);
|
||
assert.equal(fs.statSync(target).isDirectory(), true);
|
||
});
|
||
|
||
test('platformEnsureDir propagates EACCES when parent dir is unwritable', (t) => {
|
||
const dir = mkScratch('ensure-fail');
|
||
t.after(() => cleanup(dir));
|
||
|
||
const mkdirMock = mock.method(fs, 'mkdirSync', () => {
|
||
const err = new Error('EACCES: permission denied');
|
||
err.code = 'EACCES';
|
||
throw err;
|
||
});
|
||
t.after(() => mkdirMock.mock.restore());
|
||
|
||
let caught;
|
||
try {
|
||
platformEnsureDir(path.join(dir, 'cannot-create'));
|
||
} catch (err) {
|
||
caught = err;
|
||
}
|
||
assert.ok(caught);
|
||
assert.equal(caught.code, 'EACCES');
|
||
});
|
||
|
||
// ─── Symlink-following / escape behavior ────────────────────────────────────
|
||
|
||
test('platformWriteSync REPLACES a symlink with a regular file rather than following it (safe behavior)', (t) => {
|
||
// Security-relevant invariant: if the destination path is a symlink
|
||
// pointing somewhere the user did not intend the writer to touch
|
||
// (e.g. an attacker-planted symlink in `.planning/` pointing at
|
||
// `~/.ssh/authorized_keys`), the writer must NOT follow it and
|
||
// clobber the target. The rename-based atomic-write pattern delivers
|
||
// this property: `renameSync(tmp, symlinkPath)` replaces the symlink
|
||
// entry in the parent directory with the regular file at `tmp`.
|
||
// After the call:
|
||
// - `linkPath` is a regular file with the new content.
|
||
// - The original `realTarget` is UNTOUCHED.
|
||
// This test pins that property so a future refactor (e.g. switching
|
||
// to `fs.writeFileSync(linkPath, ...)` which follows symlinks) is a
|
||
// visible regression.
|
||
if (process.platform === 'win32') {
|
||
t.skip('symlinks on Win32 need admin');
|
||
return;
|
||
}
|
||
|
||
const dir = mkScratch('symlink-replace');
|
||
t.after(() => cleanup(dir));
|
||
|
||
const realTarget = path.join(dir, 'real-target.json');
|
||
fs.writeFileSync(realTarget, 'original — must not be touched\n');
|
||
const linkPath = path.join(dir, 'link.json');
|
||
fs.symlinkSync(realTarget, linkPath);
|
||
|
||
platformWriteSync(linkPath, 'new content\n');
|
||
|
||
// The real target is UNTOUCHED — the safety property.
|
||
assert.equal(
|
||
fs.readFileSync(realTarget, 'utf-8'),
|
||
'original — must not be touched\n',
|
||
'symlink target must be preserved when writer writes "through" the link',
|
||
);
|
||
// The link entry is now a regular file with the new content.
|
||
const stat = fs.lstatSync(linkPath);
|
||
assert.equal(stat.isSymbolicLink(), false, 'symlink entry replaced by a regular file (atomic rename semantics)');
|
||
assert.equal(stat.isFile(), true);
|
||
assert.equal(fs.readFileSync(linkPath, 'utf-8'), 'new content\n');
|
||
});
|
||
|
||
test('platformWriteSync against a broken symlink replaces it with the intended file', (t) => {
|
||
if (process.platform === 'win32') {
|
||
t.skip('symlinks on Win32 need admin');
|
||
return;
|
||
}
|
||
const dir = mkScratch('symlink-broken');
|
||
t.after(() => cleanup(dir));
|
||
const link = path.join(dir, 'dangling.json');
|
||
fs.symlinkSync(path.join(dir, 'does-not-exist'), link);
|
||
assert.equal(fs.lstatSync(link).isSymbolicLink(), true, 'pre-check: link is dangling');
|
||
|
||
platformWriteSync(link, 'real content\n');
|
||
|
||
assert.equal(fs.readFileSync(link, 'utf-8'), 'real content\n');
|
||
assert.equal(fs.lstatSync(link).isSymbolicLink(), false, 'broken link replaced with regular file');
|
||
});
|
||
|
||
// ─── Concurrent-write collision ────────────────────────────────────────────
|
||
|
||
test('platformWriteSync survives a concurrent collision on the same target path', (t) => {
|
||
// Two consecutive writes to the same path with DIFFERENT contents,
|
||
// separated by a setImmediate boundary so the second can interleave
|
||
// a mock-injected error mid-flight. The contract pinned here: after
|
||
// both writes complete, the file is parseable and contains ONE of the
|
||
// two contents — never a half-written corrupt blob.
|
||
//
|
||
// (True parallel writes from separate processes are out of scope —
|
||
// the writer is sync. This exercises mid-flight error recovery, which
|
||
// is the same concurrency hazard at a lower granularity.)
|
||
const dir = mkScratch('concurrent');
|
||
t.after(() => cleanup(dir));
|
||
const file = path.join(dir, 'race.json');
|
||
|
||
// First write completes normally.
|
||
platformWriteSync(file, '{"writer":"first"}\n');
|
||
// Second write: inject a transient EBUSY on the first rename attempt,
|
||
// then succeed on the bounded retry (#1540). Capture the real renameSync
|
||
// BEFORE installing the mock so the retry attempt delegates to the real
|
||
// implementation. The previous form referenced a non-existent
|
||
// `fs.renameSync.wrapped` property — that branch would silently no-op
|
||
// instead of delegating.
|
||
let renameCalls = 0;
|
||
const originalRename = fs.renameSync;
|
||
const renameMock = mock.method(fs, 'renameSync', (src, dest) => {
|
||
renameCalls++;
|
||
if (renameCalls === 1) {
|
||
const err = new Error('EBUSY: file is locked');
|
||
err.code = 'EBUSY';
|
||
throw err;
|
||
}
|
||
return originalRename.call(fs, src, dest);
|
||
});
|
||
t.after(() => renameMock.mock.restore());
|
||
|
||
platformWriteSync(file, '{"writer":"second"}\n');
|
||
|
||
// The bounded retry re-published the 'second' content atomically.
|
||
const final = fs.readFileSync(file, 'utf-8');
|
||
// Must be valid JSON — never a half-merged corruption.
|
||
assert.doesNotThrow(() => JSON.parse(final), 'file must remain parseable after the contested write');
|
||
// Must be the SECOND writer's content (it called platformWriteSync
|
||
// after the first, and the fallback completed).
|
||
assert.equal(final, '{"writer":"second"}\n');
|
||
assert.deepEqual(orphanTmpFiles(dir), [], 'no tmp orphans after the contested write');
|
||
});
|
||
});
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// #3118 — failing-first coverage for a path-export projection API that does
|
||
// not exist yet in this module (projectPathExportLine, escapeCmdDoubleQuotedArgument).
|
||
// These tests are EXPECTED TO FAIL until that API lands.
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('path export projection — escaping (#3118)', () => {
|
||
const laneCommand = (mode, targetDir, platform, shell) => {
|
||
const { shellActions } = projectPathActionProjection({ mode, targetDir, platform });
|
||
const a = shellActions.find((x) => x.shell === shell);
|
||
return a ? a.command : null;
|
||
};
|
||
|
||
// The fish lane is the ONE deliberate output change for ordinary paths: fish_add_path
|
||
// misparses a leading-dash directory name without the `--` end-of-options separator
|
||
// (#3118 review finding), so every path — hostile or ordinary — now gets `--` on the
|
||
// fish lane. Every other lane (posix export, zsh/bash echo, PowerShell, cmd) remains
|
||
// byte-identical for an ordinary path.
|
||
test('leaves an ordinary path unchanged on every lane except fish', () => {
|
||
const repairLinux = projectPathActionProjection({ mode: 'repair', targetDir: '/usr/local/bin', platform: 'linux' });
|
||
assert.deepEqual(repairLinux.shellActions, [
|
||
{ label: null, shell: 'posix', command: 'export PATH="/usr/local/bin:$PATH"' },
|
||
]);
|
||
assert.deepEqual(repairLinux.actionLines, [
|
||
'export PATH="/usr/local/bin:$PATH"',
|
||
]);
|
||
|
||
const persistLinux = projectPathActionProjection({ mode: 'persist', targetDir: '/usr/local/bin', platform: 'linux' });
|
||
assert.deepEqual(persistLinux.shellActions, [
|
||
{ label: 'zsh', shell: 'zsh', command: `echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.zshrc` },
|
||
{ label: 'bash', shell: 'bash', command: `echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.bashrc` },
|
||
{ label: 'fish', shell: 'fish', command: `fish_add_path -- '/usr/local/bin'` },
|
||
]);
|
||
assert.deepEqual(persistLinux.actionLines, [
|
||
`zsh: echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.zshrc`,
|
||
`bash: echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.bashrc`,
|
||
`fish: fish_add_path -- '/usr/local/bin'`,
|
||
]);
|
||
|
||
const repairWin32 = projectPathActionProjection({ mode: 'repair', targetDir: 'C:/Users/dev/bin', platform: 'win32' });
|
||
assert.deepEqual(repairWin32.shellActions, [
|
||
{
|
||
label: 'PowerShell',
|
||
shell: 'powershell',
|
||
command: `[Environment]::SetEnvironmentVariable('PATH', 'C:/Users/dev/bin;' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')`,
|
||
},
|
||
{
|
||
label: 'cmd.exe',
|
||
shell: 'cmd',
|
||
command: `powershell -Command "[Environment]::SetEnvironmentVariable('PATH', 'C:/Users/dev/bin;' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')"`,
|
||
},
|
||
{
|
||
label: 'Git Bash',
|
||
shell: 'bash',
|
||
command: `echo 'export PATH="C:/Users/dev/bin:$PATH"' >> ~/.bashrc`,
|
||
},
|
||
]);
|
||
});
|
||
|
||
test('the fish lane gains the end-of-options separator for every path', () => {
|
||
const command = laneCommand('persist', '/usr/local/bin', 'linux', 'fish');
|
||
assert.equal(command, `fish_add_path -- '/usr/local/bin'`);
|
||
});
|
||
|
||
test('does not write a command substitution into the rc file', () => {
|
||
const { escapedDir } = projectPathExportLine('/tmp/a$(id)b');
|
||
assert.equal(escapedDir, '/tmp/a\\$(id)b');
|
||
});
|
||
|
||
test('does not write a backtick substitution into the rc file', () => {
|
||
const { escapedDir } = projectPathExportLine('/tmp/a`whoami`b');
|
||
assert.equal(escapedDir, '/tmp/a\\`whoami\\`b');
|
||
});
|
||
|
||
test('keeps the persisted rc line quote-balanced', () => {
|
||
const { escapedDir } = projectPathExportLine('/tmp/a"b');
|
||
assert.equal(escapedDir, '/tmp/a\\"b');
|
||
});
|
||
|
||
test('escapes a backslash in the persisted line', () => {
|
||
const { escapedDir } = projectPathExportLine('/tmp/a\\b');
|
||
assert.equal(escapedDir, '/tmp/a\\\\b');
|
||
});
|
||
|
||
// #3118 review finding (now fixed): the other four escapers' target contexts
|
||
// (PowerShell single-quoted, POSIX double-quoted, POSIX single-quoted, and the win32
|
||
// JSON.stringify-based hook token) all treat a raw newline/CR/NUL as literal data that
|
||
// stays inside the quoting/escaping without breaking out, so those four DO survive
|
||
// intact and are pinned exactly below. escapeTomlDoubleQuotedString is exercised
|
||
// separately below — it now escapes TOML's required control-character ranges rather
|
||
// than passing them through raw.
|
||
test('the escapers survive newlines and null bytes', () => {
|
||
const cases = [
|
||
['\n', '\n', 'a\\nb'],
|
||
['\r\n', '\r\n', 'a\\r\\nb'],
|
||
['\0', '\0', 'a\\u0000b'],
|
||
];
|
||
for (const [raw, , tomlExpected] of cases) {
|
||
const value = `a${raw}b`;
|
||
assert.equal(escapePowerShellSingleQuoted(value), `a${raw}b`);
|
||
assert.equal(escapePosixDoubleQuoted(value), `a${raw}b`);
|
||
assert.equal(escapeSingleQuotedShellLiteral(value), `a${raw}b`);
|
||
assert.equal(formatManagedHookScriptToken(value, { platform: 'win32' }), JSON.stringify(`a${raw}b`));
|
||
assert.equal(escapeTomlDoubleQuotedString(value), tomlExpected);
|
||
}
|
||
});
|
||
|
||
// TOML v1.0.0 basic-string grammar (https://toml.io/en/v1.0.0#string): a basic string
|
||
// must escape the quotation mark, backslash, and control characters other than tab
|
||
// (U+0000-U+0008, U+000A-U+001F, U+007F). Compact escapes (\b \t \n \f \r \" \\) are
|
||
// used where TOML defines them; every other required character falls back to \uXXXX.
|
||
// Tab (U+0009) is explicitly excluded from the "must escape" set, so it passes through
|
||
// unescaped. Exact expected outputs below were derived by running the built function
|
||
// (see dispatch report table).
|
||
test('escapes control characters to a parseable TOML basic string', () => {
|
||
assert.equal(escapeTomlDoubleQuotedString('\n'), '\\n');
|
||
assert.equal(escapeTomlDoubleQuotedString('\r'), '\\r');
|
||
assert.equal(escapeTomlDoubleQuotedString('\r\n'), '\\r\\n');
|
||
assert.equal(escapeTomlDoubleQuotedString('\0'), '\\u0000');
|
||
assert.equal(escapeTomlDoubleQuotedString('\t'), '\t');
|
||
assert.equal(escapeTomlDoubleQuotedString('\b'), '\\b');
|
||
assert.equal(escapeTomlDoubleQuotedString('\f'), '\\f');
|
||
assert.equal(escapeTomlDoubleQuotedString('\x1F'), '\\u001f');
|
||
assert.equal(escapeTomlDoubleQuotedString('\x7F'), '\\u007f');
|
||
// Ordering pin: backslash escaped FIRST (doubled), then quote, then control chars —
|
||
// so a backslash introduced by an earlier escape step is never re-escaped.
|
||
assert.equal(escapeTomlDoubleQuotedString('a\\b"c\nd'), 'a\\\\b\\"c\\nd');
|
||
});
|
||
|
||
test('leaves an ordinary value byte-identical', () => {
|
||
const value = 'C:/Users/dev/gsd.js';
|
||
assert.equal(escapeTomlDoubleQuotedString(value), value);
|
||
});
|
||
|
||
test('produces a TOML basic string that parses back to the original', () => {
|
||
// No built-in or dependency TOML parser is available in this environment (Node has
|
||
// no built-in TOML support, and neither `toml` nor `@iarna/toml` nor `smol-toml` is a
|
||
// project dependency), so this round-trips through a minimal inline unescape that
|
||
// implements only the escape forms escapeTomlDoubleQuotedString can produce
|
||
// (\\ \" \b \t \n \f \r \uXXXX) — sufficient to invert this encoder, not a general
|
||
// TOML parser.
|
||
const unescapeTomlBasicString = (escaped) => {
|
||
let out = '';
|
||
for (let i = 0; i < escaped.length; i += 1) {
|
||
const ch = escaped[i];
|
||
if (ch !== '\\') {
|
||
out += ch;
|
||
continue;
|
||
}
|
||
const next = escaped[i + 1];
|
||
if (next === 'u') {
|
||
out += String.fromCodePoint(parseInt(escaped.slice(i + 2, i + 6), 16));
|
||
i += 5;
|
||
continue;
|
||
}
|
||
const compact = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '\\': '\\' }[next];
|
||
if (compact === undefined) throw new Error(`unsupported escape: \\${next}`);
|
||
out += compact;
|
||
i += 1;
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const hostileInputs = ['\n', '\r', '\r\n', '\0', '\t', '\b', '\f', '\x1F', '\x7F', 'a\\b"c\nd'];
|
||
for (const input of hostileInputs) {
|
||
const escaped = escapeTomlDoubleQuotedString(input);
|
||
const tomlLine = `k = "${escaped}"`;
|
||
const quoted = tomlLine.slice(tomlLine.indexOf('"') + 1, tomlLine.lastIndexOf('"'));
|
||
assert.equal(unescapeTomlBasicString(quoted), input, `round-trip failed for ${JSON.stringify(input)}`);
|
||
}
|
||
});
|
||
|
||
// A newline in the target directory lands inside a single-quoted `echo '...'` argument
|
||
// in the persisted bash/zsh rc lines. POSIX single quotes preserve a literal newline as
|
||
// data — they do NOT terminate on a bare newline — so the whole `echo '...' >> ~/.bashrc`
|
||
// stays ONE shell command; the newline just makes the persisted PATH assignment a broken,
|
||
// two-line value inside .bashrc, not a second executable command. (Contrast: had the
|
||
// value been embedded unquoted or inside double quotes followed by an unescaped command
|
||
// separator, a newline COULD start a new command — that is not the case here.)
|
||
test('a newline in the target directory cannot start a new rc-file command', () => {
|
||
const command = laneCommand('persist', '/tmp/a\nmalicious', 'linux', 'bash');
|
||
assert.equal(command, `echo 'export PATH="/tmp/a\nmalicious:$PATH"' >> ~/.bashrc`);
|
||
// The newline sits between the opening and closing `'` of the echo argument — it is
|
||
// literal data inside one quoted token, not a shell command terminator.
|
||
const singleQuoteSpan = command.slice(command.indexOf(`'`) + 1, command.lastIndexOf(`'`));
|
||
assert.ok(singleQuoteSpan.includes('\n'), 'the newline must be inside the single-quoted region');
|
||
});
|
||
|
||
test('keeps the existing apostrophe escaping', () => {
|
||
const command = laneCommand('persist', "/tmp/o'brien", 'linux', 'bash');
|
||
assert.ok(command.includes(`'\\''`), 'bash lane must retain the POSIX single-quote escape sequence');
|
||
});
|
||
|
||
test('escapes an apostrophe and a dollar in one path', () => {
|
||
const command = laneCommand('persist', "/tmp/o'b$(id)", 'linux', 'bash');
|
||
assert.ok(command.includes(`'\\''`), 'must still contain the POSIX single-quote escape');
|
||
assert.ok(command.includes('\\$('), 'must also contain the escaped dollar-paren');
|
||
});
|
||
|
||
test('all three lanes escape the export line identically', () => {
|
||
const { escapedDir: token } = projectPathExportLine('/tmp/a$(id)b');
|
||
const repairCommand = laneCommand('repair', '/tmp/a$(id)b', 'linux', 'posix');
|
||
const persistCommand = laneCommand('persist', '/tmp/a$(id)b', 'linux', 'bash');
|
||
const win32TargetDir = 'C:/a$(id)b';
|
||
const win32Command = laneCommand('repair', win32TargetDir, 'win32', 'bash');
|
||
// #3118: the win32 bash lane posix-normalizes targetDir before escaping it, so its token
|
||
// must be compared against the SAME (posix-normalized) input, not against the '/tmp/...'
|
||
// token above — those are two different inputs and legitimately escape differently.
|
||
const { escapedDir: win32Token } = projectPathExportLine(posixNormalize(win32TargetDir));
|
||
assert.ok(repairCommand.includes(token), 'repair posix lane must use the same escaped token');
|
||
assert.ok(persistCommand.includes(token), 'persist bash lane must use the same escaped token');
|
||
assert.ok(win32Command.includes(win32Token), 'win32 bash lane must use the same escaped token');
|
||
});
|
||
|
||
test('does not let a quote break out of the cmd lane', () => {
|
||
const { shellActions, actionLines } = projectPathActionProjection({
|
||
mode: 'repair',
|
||
targetDir: 'C:/a"&calc&"b',
|
||
platform: 'win32',
|
||
});
|
||
// `"` is a reserved character that cannot appear in any Windows path, so
|
||
// there is no valid command to suggest; the projection fails closed
|
||
// rather than emitting a cmd line whose quoting can be broken.
|
||
assert.deepEqual(shellActions, []);
|
||
assert.deepEqual(actionLines, []);
|
||
});
|
||
|
||
test('still projects win32 lanes for a legal path', () => {
|
||
const { shellActions } = projectPathActionProjection({
|
||
mode: 'repair',
|
||
targetDir: 'C:/Users/dev/bin',
|
||
platform: 'win32',
|
||
});
|
||
assert.equal(shellActions.length, 3);
|
||
assert.deepEqual(shellActions.map((a) => a.shell), ['powershell', 'cmd', 'bash']);
|
||
});
|
||
|
||
test('keeps the PowerShell doubling', () => {
|
||
assert.equal(escapePowerShellSingleQuoted("o'brien"), "o''brien");
|
||
});
|
||
|
||
test('an absent target directory produces no actions', () => {
|
||
for (const targetDir of [null, undefined, '']) {
|
||
assert.deepEqual(
|
||
projectPathActionProjection({ mode: 'repair', targetDir, platform: 'linux' }),
|
||
{ shellActions: [], actionLines: [], reason: PATH_ACTION_REASON.NO_TARGET_DIR },
|
||
);
|
||
}
|
||
});
|
||
|
||
test('reports why a win32 quote produced no actions', () => {
|
||
const result = projectPathActionProjection({ mode: 'repair', targetDir: 'C:/a"b', platform: 'win32' });
|
||
assert.deepEqual(result.shellActions, []);
|
||
assert.equal(result.reason, PATH_ACTION_REASON.WIN32_RESERVED_QUOTE);
|
||
});
|
||
|
||
// Two empty results with different causes must stay distinguishable — that
|
||
// is the whole subject of epic #3051.
|
||
test('reports a missing target directory as a different cause', () => {
|
||
const result = projectPathActionProjection({ mode: 'repair', targetDir: null, platform: 'win32' });
|
||
assert.equal(result.reason, PATH_ACTION_REASON.NO_TARGET_DIR);
|
||
});
|
||
|
||
test('a successful projection carries no reason', () => {
|
||
const result = projectPathActionProjection({ mode: 'repair', targetDir: '/usr/local/bin', platform: 'linux' });
|
||
assert.equal(Object.prototype.hasOwnProperty.call(result, 'reason'), false);
|
||
});
|
||
|
||
test('the reason vocabulary is closed', () => {
|
||
assert.deepEqual(Object.keys(PATH_ACTION_REASON).sort(), ['NO_TARGET_DIR', 'WIN32_RESERVED_QUOTE']);
|
||
assert.ok(Object.isFrozen(PATH_ACTION_REASON));
|
||
});
|
||
|
||
test('leaves the fish lane escaping unchanged', () => {
|
||
const command = laneCommand('persist', '/tmp/a$(id)b', 'linux', 'fish');
|
||
assert.equal(command, `fish_add_path -- '/tmp/a$(id)b'`);
|
||
});
|
||
|
||
test('emits the fish end-of-options separator for an ordinary path', () => {
|
||
// #3118 review MINOR: a leading-dash `targetDir` (e.g. `-v`) is a legal
|
||
// directory name, but fish's argparse-based option scanning misparses an
|
||
// unseparated leading-dash token as a flag regardless of quoting —
|
||
// verified empirically against a real fish 4.8.1 install that
|
||
// `fish_add_path '-v'` fails ("No paths to add, not setting anything.")
|
||
// while `fish_add_path -- '-v'` succeeds. `--` is fish's standard
|
||
// end-of-options separator and is a no-op for ordinary paths.
|
||
const command = laneCommand('persist', '/tmp/x', 'linux', 'fish');
|
||
assert.equal(command, `fish_add_path -- '/tmp/x'`);
|
||
});
|
||
|
||
test('an empty platform string falls back to the host', () => {
|
||
assert.equal(
|
||
formatManagedHookScriptToken('/x/y.js', { platform: '' }),
|
||
formatManagedHookScriptToken('/x/y.js'),
|
||
);
|
||
});
|
||
|
||
test('projects no token off win32', () => {
|
||
assert.equal(formatManagedHookScriptToken('/x/y.js', { platform: 'linux' }), null);
|
||
});
|
||
|
||
test('projects a JSON-quoted posix-normalized token on win32', () => {
|
||
assert.equal(
|
||
formatManagedHookScriptToken('C:\\x\\y.js', { platform: 'win32' }),
|
||
JSON.stringify('C:/x/y.js'),
|
||
);
|
||
});
|
||
|
||
test('returns no lines when called with no arguments', () => {
|
||
assert.deepEqual(renderShellActionLines(), []);
|
||
});
|
||
|
||
test('drops entries without a command', () => {
|
||
assert.deepEqual(
|
||
renderShellActionLines([null, { label: 'a' }, { label: 'b', command: 'c' }]),
|
||
['b: c'],
|
||
);
|
||
});
|
||
|
||
test('renders an unlabeled action as the bare command', () => {
|
||
assert.deepEqual(renderShellActionLines([{ label: null, command: 'x' }]), ['x']);
|
||
});
|
||
|
||
test('escapes a backslash-quote pair in the right order', () => {
|
||
// Input: a \ " b (a literal backslash immediately followed by a quote).
|
||
const input = ['a', '\\', '"', 'b'].join('');
|
||
// Expected: backslash first doubles to two backslashes, THEN the quote
|
||
// gets its own backslash — so the quote ends up preceded by 3 backslashes.
|
||
const expected = ['a', '\\', '\\', '\\', '"', 'b'].join('');
|
||
assert.equal(escapeTomlDoubleQuotedString(input), expected);
|
||
});
|
||
|
||
test('coerces non-string values', () => {
|
||
const escapers = [
|
||
escapeTomlDoubleQuotedString,
|
||
escapePowerShellSingleQuoted,
|
||
escapePosixDoubleQuoted,
|
||
escapeSingleQuotedShellLiteral,
|
||
];
|
||
for (const escaper of escapers) {
|
||
for (const value of [null, undefined, 0, [], {}]) {
|
||
assert.doesNotThrow(() => escaper(value));
|
||
assert.equal(typeof escaper(value), 'string');
|
||
}
|
||
}
|
||
});
|
||
|
||
test('returns empty for an empty value', () => {
|
||
const escapers = [
|
||
escapeTomlDoubleQuotedString,
|
||
escapePowerShellSingleQuoted,
|
||
escapePosixDoubleQuoted,
|
||
escapeSingleQuotedShellLiteral,
|
||
];
|
||
for (const escaper of escapers) {
|
||
assert.equal(escaper(''), '');
|
||
}
|
||
});
|
||
|
||
describe('retryRenameSync (#3118)', () => {
|
||
afterEach(() => {
|
||
mock.restoreAll();
|
||
});
|
||
|
||
test('rethrows when the rename cannot be retried to success', () => {
|
||
mock.method(fs, 'renameSync', () => {
|
||
const e = new Error('EPERM');
|
||
e.code = 'EPERM';
|
||
throw e;
|
||
});
|
||
assert.throws(() => retryRenameSync('/a', '/b'));
|
||
});
|
||
|
||
test('returns silently on a successful rename', (t) => {
|
||
const dir = createTempDir('gsd-3118-rename-');
|
||
t.after(() => cleanup(dir));
|
||
const from = path.join(dir, 'source.txt');
|
||
const to = path.join(dir, 'dest.txt');
|
||
fs.writeFileSync(from, 'content');
|
||
|
||
retryRenameSync(from, to);
|
||
|
||
assert.ok(fs.statSync(to).isFile());
|
||
assert.equal(fs.existsSync(from), false);
|
||
});
|
||
});
|
||
});
|