Files
msd-core/tests/bug-3441-path-action-projection.test.cjs
Andreas Brauchli cbf7c82841 feat(#323): fish-shell support in post-install PATH suggestion (#727)
* feat(#323): fish-shell support in post-install PATH suggestion

Two additive changes to the post-install PATH-suggestion seam, both scoped
to existing functions.

A. Projection: add a fish entry to the persist-mode shell-action list in
   projectPathActionProjection() (src/shell-command-projection.cts). fish has
   no `export`/`$PATH`-list syntax, so the existing zsh/bash `export PATH=...`
   commands are inert when pasted. The new entry emits the fish-native
   `fish_add_path '<dir>'` (fish 3.2+, persists via the universal-variable
   store, de-duplicating). The directory is single-quoted with the same POSIX
   literal escaping as the zsh/bash siblings; verified round-tripping through
   real fish 3.7.0 for paths containing quotes, spaces, `$`, `*`, backticks
   and unicode.

B. Detection: add homePathCoveredByFishConfig() in bin/install.js, called
   from maybeSuggestPathExport() alongside homePathCoveredByRc(). fish does
   not use sh-style `export PATH=` rc files, so a fish user whose
   fish_user_paths already covers the global bin would otherwise get a
   false-positive "not on your PATH" warning on every install. Two
   side-effect-free detection routes (no fish subprocess):

   1. The universal-variable store (~/.config/fish/fish_variables). fish
      serializes this with `full_escape`: every byte outside [A-Za-z0-9/_]
      becomes `\xHH` (space -> \x20, `-` -> \x2d, `.` -> \x2e, `$` -> \x24,
      unicode -> \uXXXX) and list elements are joined by the literal 4-char
      token `\x1e` (NOT a raw 0x1e byte). The detector splits on `\x1e`,
      decodes the escapes, then compares each as an absolute literal — a
      decoded `$` is part of the directory name, not an unexpanded variable.
      Verified against real fish 3.7.0 output.
   2. config.fish (`fish_add_path`, `set -gx PATH`, `set -Ux fish_user_paths`)
      — plain shell tokens: HOME forms ($HOME/${HOME}/~) are expanded and a
      token still holding `$` (e.g. `$PATH`, `$fish_user_paths`) is skipped.

   Honours $XDG_CONFIG_HOME and always also checks ~/.config/fish.

No behaviour change for bash/zsh/PowerShell/cmd/Git-Bash users: their entries
and command strings are unchanged; the fish entry is additive and the fish
detector only narrows the set of cases that warn.

Tests: update the projection length assertion (2 -> 3) and fish escaping in
bug-3441; add fish detection + suppression cases in install-path-detection
(uvar store with real fish escaping, dot/hyphen/space/$-literal decode
regressions, config.fish routes, commented-out, relative-segment guard,
unreadable-file fault injection, suppression and emission via
maybeSuggestPathExport).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(changeset): add Changed fragment for #323 fish PATH support (#727)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#323): address review — action-only fish docs, decoder property test, win32 guard

Addresses @trek-e's review on #727:

- docs (blocker): keep the how-to action-only (Diátaxis). Drop the
  `# fish — persists via …` comment and the internal-mechanism clause
  naming fish_variables/config.fish; leave one command + the exec-fish
  directive.
- tests (minor): extract decodeFishUniversalValue to a pure, exported
  module function and add fast-check round-trip properties
  (decode(fishEscape(p)) === p over arbitrary unicode, abs-path variant,
  totality). Consolidated into install-path-detection.test.cjs to respect
  the install test-file-count ratchet.
- tests (follow-up): port #721's win32 negative-projection test (no fish
  action on win32; persist projection is PowerShell/cmd.exe/Git Bash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#323): address review — drop unused 'after' import, clarify escaping comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-06-24 17:20:52 -04:00

137 lines
4.8 KiB
JavaScript

'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).
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);
}
});
});