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