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>
This commit is contained in:
Nick Shirokov
2026-08-22 19:27:02 +03:00
co-authored by androman.pro Claude Opus 5
parent 36bae190b2
commit 0442aa015a
16 changed files with 529 additions and 60 deletions
@@ -0,0 +1,116 @@
// web-test core/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);
}
@@ -1,10 +1,11 @@
// web-test core/session v1.20 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
// web-test core/session v1.21 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { chromium } from 'playwright';
import { softDeadline } from './deadline.mjs';
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
import { statSync, mkdirSync, readdirSync } from 'fs';
import { join as pathJoin } from 'path';
import { removePathSync } from './fsutil.mjs';
import { tmpdir } from 'os';
import {
browser, page, sessionPrefix, seanceId, recorder, highlightMode,
@@ -271,7 +272,7 @@ export async function disconnect() {
setSeanceId(null);
// Clean up persistent user data dir
if (persistentUserDataDir) {
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
try { removePathSync(persistentUserDataDir); } catch {}
setPersistentUserDataDir(null);
}
}
@@ -696,7 +697,7 @@ export async function abortContext(name, { logoutMs = 3000, closeMs = 5000, park
setActiveContextName(null);
setActiveMode(null);
if (persistentUserDataDir) {
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
try { removePathSync(persistentUserDataDir); } catch {}
setPersistentUserDataDir(null);
}
return out;
@@ -715,7 +716,7 @@ export async function abortContext(name, { logoutMs = 3000, closeMs = 5000, park
setActiveContextName(null);
setActiveMode(null);
if (persistentUserDataDir) {
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
try { removePathSync(persistentUserDataDir); } catch {}
setPersistentUserDataDir(null);
}
return out;
@@ -1,9 +1,10 @@
// web-test recording/narration v1.17 — Post-process: generate TTS audio for captions and merge with recorded video.
// web-test recording/narration v1.18 — Post-process: generate TTS audio for captions and merge with recorded video.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { execFileSync } from 'child_process';
import { existsSync as fsExistsSync, mkdirSync, readFileSync, rmSync, statSync } from 'fs';
import { existsSync as fsExistsSync, mkdirSync, readFileSync, statSync } from 'fs';
import { extname, join as pathJoin } from 'path';
import { removePathSync } from '../core/fsutil.mjs';
import { tmpdir } from 'os';
import {
lastCaptions, lastRecordingDuration, resolveProjectPath,
@@ -191,6 +192,6 @@ export async function addNarration(videoPath, opts = {}) {
} finally {
// Cleanup temp directory
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
try { removePathSync(tempDir); } catch {}
}
}