Files
cc-1c-skills/tests/common/fsutil.mjs
T
0442aa015a fix(tests,web-test): не отдавать rmSync/cpSync пути с не-ASCII символами
На Windows fs.rmSync и fs.cpSync молча не делают ничего, когда не-ASCII символы
есть в самом аргументе пути (nodejs/node#61067). Для аудитории проекта это боевой
сценарий: кириллическое имя пользователя даёт кириллический %TEMP%, где раннер
создаёт воркспейсы, плюс кириллические имена объектов 1С в путях внутри кейсов.

Замерено на восьми сборках. Затронуты 22.18.0–22.23.2 (последняя LTS Jod) и
24.12.0–24.14.0; исправны 22.15.1, 24.15.0+, 25.x, 26.x. Фикс приехал в fs-слой
Node 24.15 и в ветку 22.x не бэкпортирован, поэтому «обновить Node» вопрос не
закрывает. Зависимость не монотонна по версиям (24.14 чинит rmSync, но не
cpSync) — гард по номеру версии невозможен, решает только сам путь.

Симптомы: rmSync молча ничего не удаляет; cpSync с не-ASCII приёмником молча
ничего не копирует; cpSync с не-ASCII источником валит процесс нативно
(0xC0000409) мимо try/catch. Не затронуты mkdirSync, readdirSync, lstatSync,
copyFileSync (включая перезапись), unlinkSync, rmdirSync — на них стоит обход.

Что ломалось: под кириллическим %TEMP% фикстуры не доезжали до воркспейсов
(meta-info — 15 ложных падений из 26, один кейс ложно-зелёный на пустом
воркспейсе); cfe-validate/module-state-flag-without-file был красным даже при
ASCII %TEMP% (кириллица в самом deletePath); --update-snapshots молча не сносил
старый эталон, то есть портил коммитимые артефакты.

Реализация — единый fsutil в двух побайтно одинаковых копиях (tests/common/ и
внутри автономного навыка web-test), раскатанный на все 42 точки вызова в 11
файлах: tests/skills/*, tests/web-test/*, hooks/test/run.mjs, движок web-test.
Предикат судит по resolve(p), а не по строке аргумента: относительный
ASCII-аргумент при не-ASCII cwd платформа роняет так же молча. Ретраи доживают
до ручного обхода, перезапись как у cpSync с force: true, настоящие ошибки не
глотаются, выживший после удаления путь — громкая ошибка.

Гард tests/skills/check-nonascii-fs.mjs в check-all.mjs проверяет хелпер, а не
платформу (поэтому зелёный и на исправной Node), сверяет хеши обеих копий и
печатает справкой состояние текущей сборки.

Co-Authored-By: androman.pro <5669019+andromanpro@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:27:02 +03:00

117 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tests/common/fsutil v1.0 — удаление и копирование, устойчивые к не-ASCII путям на Windows.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
// ─── fsutil:begin ───────────────────────────────────────────────────────────
// Ниже этого маркера файл побайтно совпадает со второй копией. Расхождение ловит
// tests/skills/check-nonascii-fs.mjs. Правку вносим в обе копии сразу.
import { cpSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync,
rmSync, rmdirSync, unlinkSync } from 'node:fs';
import { join, resolve } from 'node:path';
// На Windows fs.rmSync и fs.cpSync ломаются, когда не-ASCII символы есть в САМОМ аргументе
// пути (nodejs/node#61067, апстрим открыт). Снято на стенде из восьми сборок:
//
// сборка rmSync cpSync → приёмник cpSync ← источник
// 22.15.1 ок ок ок
// 22.18.0 … 22.23.2 (LTS Jod) ок молча ничего краш 0xC0000409
// 24.12.0, 24.13.0 молча молча ничего краш
// 24.14.0 ок молча ничего краш
// 24.15.0, 24.19.0, 25.9.0, 26.7.0 ок ок ок
//
// Это НЕ обход одного старого релиза: ветка 22 LTS сломана до сих пор — фикс приехал в
// fs-слой Node 24.15 и в 22.x не бэкпортирован, а README проекта рекомендует «Node.js 18+».
// Зависимость не монотонна по версиям (24.14 чинит rmSync, но не cpSync), поэтому гард по
// номеру версии невозможен в принципе: решает только сам путь. Снимать гард можно, лишь
// когда исправной станет вся поддерживаемая линейка, а не текущая машина разработчика.
//
// mkdirSync, readdirSync, lstatSync, existsSync, copyFileSync (включая перезапись),
// unlinkSync, rmdirSync с не-ASCII исправны на ВСЕХ сломанных сборках — на них и стоит
// ручной обход. Асинхронные fs.promises.rm/cp не затронуты.
const NON_ASCII = /[^\x00-\x7F]/;
const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'ENOTEMPTY', 'EMFILE']);
// Проверяем РАЗРЕШЁННЫЙ путь, а не строку аргумента: относительный ASCII-аргумент при
// не-ASCII cwd платформа роняет так же молча, а по строке аргумента это не видно.
export function pathIsUnsafe(p) {
// Тест-шов только для check-nonascii-fs.mjs: без него ручной обход никогда не исполняется
// на macOS/Linux и остаётся непокрытым. В обычном прогоне переменная не задана.
if (process.env.CC1C_FSUTIL_FORCE_WALK === "1") return true;
return process.platform === 'win32' && NON_ASCII.test(resolve(p));
}
function sleepSync(ms) {
if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function withRetrySync(fn, maxRetries, retryDelay) {
for (let attempt = 0; ; attempt++) {
try { return fn(); }
catch (e) {
if (attempt >= maxRetries || !RETRY_CODES.has(e.code)) throw e;
sleepSync(retryDelay);
}
}
}
// Симлинк или junction на каталог: unlinkSync на нём даёт EPERM, снимается rmdirSync.
// Внутрь не заходим — rmSync тоже сносит саму ссылку, а не её цель.
function removeLeafSync(p) {
try { unlinkSync(p); }
catch (e) {
if (e.code !== 'EPERM' && e.code !== 'EISDIR') throw e;
rmdirSync(p);
}
}
function removeTreeWalkSync(dir, maxRetries, retryDelay) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const child = join(dir, entry.name);
if (entry.isDirectory()) removeTreeWalkSync(child, maxRetries, retryDelay);
else withRetrySync(() => removeLeafSync(child), maxRetries, retryDelay);
}
withRetrySync(() => rmdirSync(dir), maxRetries, retryDelay);
}
// Семантика rm -rf: файл, каталог или отсутствующий путь. opts.maxRetries / opts.retryDelay
// работают на обоих путях — на быстром их принимает сам rmSync, на ручном обходе повторяем сами.
export function removePathSync(target, opts = {}) {
const { maxRetries = 0, retryDelay = 100 } = opts;
if (!pathIsUnsafe(target)) {
rmSync(target, { recursive: true, force: true, maxRetries, retryDelay });
// Тихий отказ ровно здесь и есть болезнь: подтверждаем результат одним existsSync.
if (!existsSync(target)) return;
} else if (!existsSync(target)) {
return;
}
if (lstatSync(target).isDirectory()) removeTreeWalkSync(target, maxRetries, retryDelay);
else withRetrySync(() => removeLeafSync(target), maxRetries, retryDelay);
if (existsSync(target)) throw new Error(`Не удалось удалить путь: ${target}`);
}
function copyTreeWalkSync(src, dest) {
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src, { withFileTypes: true })) {
const from = join(src, entry.name);
const to = join(dest, entry.name);
// Перезаписываем безусловно — как cpSync с дефолтным force: true.
if (entry.isDirectory()) copyTreeWalkSync(from, to);
else copyFileSync(from, to);
}
}
// Рекурсивная копия КАТАЛОГА с перезаписью. Для одиночного файла хватает copyFileSync:
// он не затронут и работает с не-ASCII на всех сборках.
export function copyTreeSync(src, dest) {
if (!pathIsUnsafe(src) && !pathIsUnsafe(dest)) {
// Ошибки не глотаем: настоящий ENOENT/EACCES должен долететь до вызывающего.
cpSync(src, dest, { recursive: true });
return;
}
copyTreeWalkSync(src, dest);
}