mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
process.argv[1] keeps the path as typed, so a script started through a symlink (or from a symlinked directory) still carries the link path there, while Node builds import.meta.url of the main module from the real path. The two never matched, and every CLI guarded by isMainModule silently did nothing and exited 0. Both sides are now resolved with realpathSync before the pathToFileURL comparison; a path that does not exist is compared as is, without throwing, exactly as before. The unit test writes a CLI and a module it imports into a temporary directory, launches the CLI directly, through a directory link (a junction on Windows, no admin rights needed) and through a file symlink (skipped on EPERM), and checks that only the launched script runs its main. It is red on the previous implementation. Issue: #733 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
113 lines
6.9 KiB
JavaScript
113 lines
6.9 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
import { isMainModule, portableCommand } from '../scripts/spawn-portable.mjs';
|
||
|
||
// #496: на машине владельца (Windows) три инфраструктурных теста падали при
|
||
// зелёном Linux CI. Причины — не логика, а переносимость: разбор argv[1] через
|
||
// `file://`, запуск команд через оболочку, CRLF из глобального git config.
|
||
// Здесь закреплены правила, чтобы они не вернулись с очередным скриптом.
|
||
|
||
const SCRIPTS = fileURLToPath(new URL('../scripts/', import.meta.url));
|
||
const scriptSources = () => readdirSync(SCRIPTS)
|
||
.filter((name) => name.endsWith('.mjs'))
|
||
.map((name) => ({ name, text: readFileSync(new URL(name, `file://${SCRIPTS}`), 'utf8') }));
|
||
|
||
test('скрипты не собирают file-URL конкатенацией с argv (#496)', () => {
|
||
const offenders = scriptSources()
|
||
.filter(({ text }) => /new URL\(`file:\/\/\$\{process\.argv/.test(text))
|
||
.map(({ name }) => name);
|
||
assert.deepEqual(offenders, [], 'на Windows это даёт file:///C:/C:/… и CLI молчит; использовать isMainModule');
|
||
});
|
||
|
||
test('скрипты не включают оболочку по платформе — только portableCommand (#496)', () => {
|
||
const offenders = scriptSources()
|
||
.filter(({ name, text }) => name !== 'spawn-portable.mjs' && /shell:\s*process\.platform\s*===\s*'win32'/.test(text))
|
||
.map(({ name }) => name);
|
||
assert.deepEqual(offenders, [], 'оболочка нужна только npm.cmd; node/git через shell теряют кавычки');
|
||
});
|
||
|
||
test('portableCommand: оболочка только для npm/npx/.cmd на Windows (#496)', () => {
|
||
assert.deepEqual(portableCommand('npm', 'win32'), { cmd: 'npm.cmd', shell: true });
|
||
assert.deepEqual(portableCommand('npx', 'win32'), { cmd: 'npx.cmd', shell: true });
|
||
assert.deepEqual(portableCommand('tool.CMD', 'win32'), { cmd: 'tool.CMD', shell: true });
|
||
assert.deepEqual(portableCommand(process.execPath, 'win32'), { cmd: process.execPath, shell: false });
|
||
assert.deepEqual(portableCommand('git', 'win32'), { cmd: 'git', shell: false });
|
||
assert.deepEqual(portableCommand('npm', 'linux'), { cmd: 'npm', shell: false });
|
||
assert.deepEqual(portableCommand('npm', 'darwin'), { cmd: 'npm', shell: false });
|
||
});
|
||
|
||
test('isMainModule сравнивает через pathToFileURL и терпит мусор в argv (#496)', () => {
|
||
const here = fileURLToPath(import.meta.url);
|
||
assert.equal(isMainModule(import.meta.url, here), true);
|
||
assert.equal(isMainModule(pathToFileURL(here).href, here), true);
|
||
assert.equal(isMainModule(import.meta.url, `${here}.other`), false);
|
||
assert.equal(isMainModule(import.meta.url, null), false);
|
||
assert.equal(isMainModule(import.meta.url, ''), false);
|
||
});
|
||
|
||
test('isMainModule: запуск через симлинк исполняет main, импортированный модуль — нет (#733)', () => {
|
||
// argv[1] остаётся путём симлинка, import.meta.url главного модуля — реальный
|
||
// путь. До #733 такой запуск молча выходил с кодом 0, ничего не сделав.
|
||
const root = mkdtempSync(join(tmpdir(), 'hp-733-main-'));
|
||
try {
|
||
const real = join(root, 'real');
|
||
mkdirSync(real);
|
||
const portable = pathToFileURL(fileURLToPath(new URL('../scripts/spawn-portable.mjs', import.meta.url))).href;
|
||
const probe = (label) => `import { isMainModule } from ${JSON.stringify(portable)};\n`
|
||
+ `if (isMainModule(import.meta.url)) console.log(${JSON.stringify(label)});\n`;
|
||
writeFileSync(join(real, 'lib.mjs'), probe('lib-main'));
|
||
writeFileSync(join(real, 'cli.mjs'), `import './lib.mjs';\n${probe('cli-main')}`);
|
||
|
||
// Каталог-симлинк: на Windows это junction, прав администратора он не требует.
|
||
const dirLink = join(root, 'dir-link');
|
||
symlinkSync(real, dirLink, 'junction');
|
||
const launches = [join(real, 'cli.mjs'), join(dirLink, 'cli.mjs')];
|
||
// Симлинк на сам файл: на Windows без режима разработчика — EPERM, тогда
|
||
// проверяется только каталог-симлинк.
|
||
const fileLink = join(root, 'cli-link.mjs');
|
||
try {
|
||
symlinkSync(join(real, 'cli.mjs'), fileLink, 'file');
|
||
launches.push(fileLink);
|
||
} catch (error) {
|
||
if (error.code !== 'EPERM') throw error;
|
||
}
|
||
|
||
for (const script of launches) {
|
||
const run = spawnSync(process.execPath, [script], { encoding: 'utf8' });
|
||
assert.equal(run.status, 0, `${script}: ${run.stderr}`);
|
||
assert.deepEqual(run.stdout.trim().split(/\r?\n/), ['cli-main'],
|
||
`${script}: main исполняется ровно у запущенного скрипта, не у импортированного`);
|
||
}
|
||
|
||
// Та же развилка без дочернего процесса.
|
||
const cliUrl = pathToFileURL(join(real, 'cli.mjs')).href;
|
||
const libUrl = pathToFileURL(join(real, 'lib.mjs')).href;
|
||
assert.equal(isMainModule(cliUrl, join(dirLink, 'cli.mjs')), true);
|
||
assert.equal(isMainModule(libUrl, join(dirLink, 'cli.mjs')), false);
|
||
// Несуществующий путь — прежнее сравнение как есть, без исключения.
|
||
assert.equal(isMainModule(cliUrl, join(root, 'missing.mjs')), false);
|
||
assert.equal(isMainModule(pathToFileURL(join(root, 'missing.mjs')).href, join(root, 'missing.mjs')), true);
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('тесты с временным git-репозиторием изолируют переводы строк от глобального конфига (#496)', () => {
|
||
const text = readFileSync(new URL('./rebase-on-dev.test.mjs', import.meta.url), 'utf8');
|
||
assert.match(text, /GIT_CONFIG_KEY_0: 'core\.autocrlf', GIT_CONFIG_VALUE_0: 'false'/);
|
||
});
|
||
|
||
test('тесты не запускают скрипты по URL.pathname — на Windows это /C:/… → C:\\C:\\… (#496)', () => {
|
||
const TESTS = fileURLToPath(new URL('./', import.meta.url));
|
||
const offenders = readdirSync(TESTS)
|
||
.filter((name) => name.endsWith('.test.mjs'))
|
||
.filter((name) => /import\.meta\.url\)\.pathname/.test(readFileSync(new URL(name, `file://${TESTS}`), 'utf8')))
|
||
.filter((name) => name !== 'windows-portability.test.mjs');
|
||
assert.deepEqual(offenders, [], 'путь для execSync/spawn — только через fileURLToPath');
|
||
});
|