From b9ef53e5088427865f10b5f3167cbe48c4f8a1aa Mon Sep 17 00:00:00 2001
From: somepoi <46904988+somepoi@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:27:34 +0300
Subject: [PATCH 01/27] feature: rpa packer + color picker fixes (wip)
---
app/routes/tools.py | 3 +-
app/utils/lifespan/search_cache.py | 2 +-
static/css/colors.css | 1 +
static/css/tools.css | 18 ++-
static/js/colors.js | 67 +++++++--
static/js/tools-core.js | 32 ++++-
static/js/tools-worker.js | 54 +++++++-
static/js/tools.js | 213 +++++++++++++++++++++++++----
static/tools/rpa.py | 74 ++++++++++
templates/tools.html | 2 +-
templates/tools_unpack.html | 49 ++++++-
tests/rpa_browser.cjs | 167 ++++++++++++++++++++++
tests/test_tools.py | 7 +-
tests/test_tools_rpa.py | 83 +++++++++++
tests/tools_core.test.mjs | 17 +++
tests/tools_pyodide.test.mjs | 13 ++
tests/tools_ui.test.mjs | 134 +++++++++++++++++-
17 files changed, 885 insertions(+), 51 deletions(-)
create mode 100644 static/tools/rpa.py
create mode 100644 tests/rpa_browser.cjs
create mode 100644 tests/test_tools_rpa.py
diff --git a/app/routes/tools.py b/app/routes/tools.py
index f6a9e5f..c34191a 100644
--- a/app/routes/tools.py
+++ b/app/routes/tools.py
@@ -15,7 +15,7 @@
'engine.py': 'text/plain', 'recovery.py': 'text/plain', 'vendor.zip': 'application/zip',
'pyc_decompiler.py': 'text/plain', 'bytecode.zip': 'application/zip',
'syntax.zip': 'application/zip', 'renpy_lexer.py': 'text/plain',
- 'manifest.json': 'application/json', 'NOTICE.txt': 'text/plain',
+ 'manifest.json': 'application/json', 'NOTICE.txt': 'text/plain', 'rpa.py': 'text/plain',
**{f'pyodide/{RUNTIME_VERSION}/{name}': media for name, media in {
'pyodide.mjs': 'application/javascript', 'pyodide.asm.js': 'application/javascript',
'pyodide.asm.wasm': 'application/wasm', 'python_stdlib.zip': 'application/zip',
@@ -55,6 +55,7 @@ async def unpack_page(request: Request):
return templates.TemplateResponse(request, 'tools_unpack.html', {
'tools_config': {
'worker': asset_url('/static/js/tools-worker.js'),
+ 'packer': f'/static/tools/rpa.py?v={digest("rpa.py")}',
'engine': f'/static/tools/engine.py?v={digest("engine.py")}',
'recovery': f'/static/tools/recovery.py?v={digest("recovery.py")}',
'pyc': f'/static/tools/pyc_decompiler.py?v={digest("pyc_decompiler.py")}',
diff --git a/app/utils/lifespan/search_cache.py b/app/utils/lifespan/search_cache.py
index 47ecbd5..adfa153 100644
--- a/app/utils/lifespan/search_cache.py
+++ b/app/utils/lifespan/search_cache.py
@@ -17,7 +17,7 @@
('Специалисты', '/specialists'),
('Материалы', '/materials'),
('Инструменты', '/tools'),
- ('Распаковщик RPA/RPYC/PYC RenPy / Python / UnRpyc / UnRPA / uncompyle6', '/tools/unpack'),
+ ('Упаковщик и распаковщик RPA / Декомпиляция RPYC/PYC / RenPy / Python / UnRpyc / UnRPA / uncompyle6', '/tools/unpack'),
('Палитра цветов / Цвет / HEX / RGB / Палитры', '/tools/colors'),
('Варперы / Сглаживание ATL / Тестер формул', '/tools/warpers'),
('Площадки для публикации', '/news-resources'),
diff --git a/static/css/colors.css b/static/css/colors.css
index 48b8dfc..96ef6c6 100644
--- a/static/css/colors.css
+++ b/static/css/colors.css
@@ -72,6 +72,7 @@
.picker-shortcuts p { color: var(--text-soft); margin: 12px 0; }
.picker-shortcuts .picker-section-head { margin-bottom: 20px; }
.picker-eyedropper-overlay { position: fixed; inset: 0; z-index: 10000; background: rgb(0 0 0 / .6); cursor: crosshair; }
+.picker-eyedropper-overlay-capture { background: transparent; }
.picker-eyedropper-help { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: min(360px, calc(100% - 32px)); box-sizing: border-box; padding: 20px 24px; border-radius: var(--radius-block); background: var(--bg); color: var(--text); box-shadow: 0 8px 32px #0004; }
.picker-eyedropper-help h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 14px; font-size: var(--fs-body); }
.picker-eyedropper-help p { display: flex; align-items: center; gap: 16px; margin: 8px 0; font-size: var(--fs-ui); }
diff --git a/static/css/tools.css b/static/css/tools.css
index db93d21..f44a933 100644
--- a/static/css/tools.css
+++ b/static/css/tools.css
@@ -1,6 +1,16 @@
.tools-page .page-main { max-width: 66rem; }
.tools-heading h2, .tools-links h2, #site-tools-heading { font-family: var(--font-serif); font-size: var(--fs-subtitle); font-weight: 700; margin: 0 0 8px; }
.tools-heading p { color: var(--text-soft); margin: 0 0 24px; }
+.tools-mode { display: flex; flex-wrap: wrap; gap: 8px 24px; border: 0; padding: 0; margin: 0 0 20px; font-size: var(--fs-ui); }
+.tools-mode label { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; }
+.tools-mode label:has(:checked) { font-weight: 600; }
+.tools-pack-settings { margin-block: 20px 24px; }
+.tools-pack-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-bottom: 12px; }
+.tools-pack-fields label { display: grid; gap: 8px; font-size: var(--fs-ui); font-weight: 600; min-width: 0; }
+.tools-pack-fields :is(input, select) { width: 100%; min-width: 0; min-height: 44px; padding: 8px 10px; border: 1px solid var(--text-soft); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; font-weight: 400; caret-color: var(--accent); }
+.tools-pack-fields input::placeholder { color: var(--text-soft); opacity: 1; }
+.tools-pack-fields [aria-invalid="true"] { border-color: var(--danger); }
+@media (max-width: 700px) { .tools-pack-fields { grid-template-columns: minmax(0, 1fr); } }
.tools-drop { border: 1px dashed var(--border); padding: 30px 20px 22px; text-align: center; background: var(--bg-light); border-radius: var(--radius-sm); }
.tools-drop.is-dragging { border-color: var(--accent); border-style: solid; background: var(--accent-muted); }
.tools-drop-icon { color: var(--text-soft); margin-bottom: 10px; }
@@ -11,7 +21,7 @@
.tools-button-primary { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.tools-button-primary:hover { background: var(--accent-hover); color: var(--bg); }
.tools-button:disabled { background: var(--bg-light); color: var(--text-soft); border-color: var(--border); cursor: default; }
-.tools-page :is(button, a, input, summary, .tools-drop):focus-visible { outline: var(--focus-ring); outline-offset: 3px; }
+.tools-page :is(button, a, input, select, summary, .tools-drop):focus-visible { outline: var(--focus-ring); outline-offset: 3px; }
.tools-add-menu { position: fixed; z-index: 110; min-width: 210px; padding: 6px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-sm); }
.tools-add-menu button { display: block; width: 100%; min-height: 44px; padding: 10px 14px; background: none; border: 0; text-align: start; color: var(--text); font: inherit; font-size: var(--fs-ui); cursor: pointer; }
.tools-add-menu button:hover { background: var(--bg-light); }
@@ -31,6 +41,11 @@
.tools-queue-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
.tools-queue-summary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 16px; min-width: 0; }
.tools-queue-summary .tools-meta { margin: 0; }
+.tools-queue-tools { margin-top: 16px; }
+.tools-queue-tools > label { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; font-size: var(--fs-ui); font-weight: 600; }
+.tools-browser-search.tools-queue-search { border-color: var(--text-soft); }
+.tools-queue-navigation, .tools-queue-pages { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; }
+.tools-queue-navigation { justify-content: space-between; margin-top: 12px; }
.tools-clear { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--radius-sm); background: none; color: var(--text-soft); cursor: pointer; }
.tools-clear:hover:enabled { color: var(--danger); background: var(--bg-light); }
.tools-clear:disabled { opacity: 0.5; cursor: default; }
@@ -41,6 +56,7 @@
.tools-file-list li { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 12px; min-height: 52px; padding: 6px 4px; }
.tools-file-list li + li { border-top: 1px solid var(--border); }
.tools-file-name { overflow-wrap: anywhere; font-size: var(--fs-ui); }
+.tools-pack-path { display: block; padding-block: 6px; color: var(--text); }
.tools-file-size { font-size: var(--fs-ui); color: var(--text-soft); font-variant-numeric: tabular-nums; }
.tools-file-state { display: block; color: var(--text-soft); font-size: var(--fs-ui); }
.tools-file-state.is-error { color: var(--danger); }
diff --git a/static/js/colors.js b/static/js/colors.js
index 6639a95..db1cb99 100644
--- a/static/js/colors.js
+++ b/static/js/colors.js
@@ -11,7 +11,7 @@
let color = c.parseHex('#2f7524'), hsv = c.rgbToHsv(color), other = c.parseHex('#fff');
let role = 'text', surface = 'field', pointer = null, disposed = false, imageRequest = 0, imagePoint = {x: 0, y: 0};
const undos = [], redos = [], copyTimers = new Map();
- let eyedropperOverlayTimer, eyedropperController;
+ let eyedropperOverlayTimer, eyedropperController, screenCaptureStream, screenCaptureVideo, screenPickResolve;
const same = (first, second) => JSON.stringify(first) === JSON.stringify(second);
const readList = key => {
try {
@@ -437,7 +437,27 @@
on(document, 'pointerdown', event => { if (event.button === 2) cancelEyedropper(event); });
on(document, 'contextmenu', cancelEyedropper);
on(document, 'keydown', event => { if (event.key === 'Escape') cancelEyedropper(event); });
- if (window.EyeDropper && window.isSecureContext) {
+ on(get('eyedropper-overlay'), 'pointerdown', event => {
+ if (!screenCaptureVideo || event.button !== 0 || get('eyedropper-overlay').hidden) return;
+ if (event.target.closest?.('.picker-eyedropper-help')) return;
+ event.preventDefault();
+ try {
+ const sampleCanvas = document.createElement('canvas');
+ const scale = Math.min(1, 4096 / screenCaptureVideo.videoWidth, 4096 / screenCaptureVideo.videoHeight, Math.sqrt(12000000 / (screenCaptureVideo.videoWidth * screenCaptureVideo.videoHeight)));
+ sampleCanvas.width = Math.max(1, Math.round(screenCaptureVideo.videoWidth * scale)); sampleCanvas.height = Math.max(1, Math.round(screenCaptureVideo.videoHeight * scale));
+ const context = sampleCanvas.getContext('2d', {willReadFrequently: true});
+ context.drawImage(screenCaptureVideo, 0, 0, sampleCanvas.width, sampleCanvas.height);
+ const x = c.clamp(Math.floor(event.clientX / window.innerWidth * screenCaptureVideo.videoWidth * scale), 0, sampleCanvas.width - 1);
+ const y = c.clamp(Math.floor(event.clientY / window.innerHeight * screenCaptureVideo.videoHeight * scale), 0, sampleCanvas.height - 1);
+ const pixel = context.getImageData(x, y, 1, 1).data;
+ screenPickResolve?.({r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] / 255});
+ } catch (_) {
+ get('status').textContent = 'Не удалось прочитать цвет экрана. Выберите вкладку Firefox в окне захвата.';
+ }
+ });
+ const nativeEyedropper = window.EyeDropper && window.isSecureContext;
+ const screenEyedropper = window.isSecureContext && window.navigator.mediaDevices?.getDisplayMedia;
+ if (nativeEyedropper || screenEyedropper) {
get('eyedropper').hidden = false;
on(get('eyedropper'), 'click', async () => {
const pickController = new AbortController();
@@ -445,12 +465,40 @@
const abortPick = () => pickController.abort();
controller.signal.addEventListener('abort', abortPick, {once: true});
get('eyedropper').disabled = true;
- // Chromium snapshots the screen on open. Delay the dimmer until after
- // that capture so the magnifier samples the original page colors.
- eyedropperOverlayTimer = setTimeout(() => { if (!disposed) get('eyedropper-overlay').hidden = false; }, 300);
- try { const result = await new window.EyeDropper().open({signal: pickController.signal}); if (!disposed && !pickController.signal.aborted) choose(c.parseHex(result.sRGBHex)); }
- catch (error) { if (!disposed) get('status').textContent = error.name === 'AbortError' ? 'Выбор пипеткой отменён.' : 'Не удалось открыть пипетку. Используйте поле или изображение.'; }
- finally { controller.signal.removeEventListener('abort', abortPick); if (eyedropperController === pickController) eyedropperController = null; clearTimeout(eyedropperOverlayTimer); if (!disposed) { get('eyedropper').disabled = false; get('eyedropper-overlay').hidden = true; get('eyedropper').focus({preventScroll: true}); } }
+ try {
+ let result;
+ if (nativeEyedropper) {
+ // Chromium snapshots the screen on open. Delay the dimmer until after
+ // that capture so the magnifier samples the original page colors.
+ eyedropperOverlayTimer = setTimeout(() => { if (!disposed) get('eyedropper-overlay').hidden = false; }, 300);
+ result = c.parseHex((await new window.EyeDropper().open({signal: pickController.signal})).sRGBHex);
+ } else {
+ get('status').textContent = 'В окне Firefox выберите эту вкладку для захвата экрана.';
+ screenCaptureStream = await window.navigator.mediaDevices.getDisplayMedia({video: true, audio: false, preferCurrentTab: true});
+ screenCaptureVideo = document.createElement('video');
+ screenCaptureVideo.muted = true; screenCaptureVideo.playsInline = true; screenCaptureVideo.srcObject = screenCaptureStream;
+ await screenCaptureVideo.play();
+ if (!screenCaptureVideo.videoWidth || !screenCaptureVideo.videoHeight) throw new Error('Captured tab is not ready');
+ if (pickController.signal.aborted || disposed) return;
+ get('eyedropper-overlay').classList.add('picker-eyedropper-overlay-capture');
+ get('eyedropper-overlay').hidden = false;
+ get('status').textContent = 'Нажмите на нужный цвет. Esc отменяет выбор.';
+ result = await new Promise((resolve, reject) => {
+ screenPickResolve = resolve;
+ pickController.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), {once: true});
+ });
+ }
+ if (!disposed && !pickController.signal.aborted) choose(result);
+ }
+ catch (error) { if (!disposed) get('status').textContent = error.name === 'AbortError' ? 'Выбор пипеткой отменён.' : error.name === 'NotAllowedError' ? 'Захват вкладки отменён. Разрешите его или выберите цвет из изображения.' : 'Не удалось открыть пипетку. Используйте поле или изображение.'; }
+ finally {
+ controller.signal.removeEventListener('abort', abortPick);
+ if (eyedropperController === pickController) eyedropperController = null;
+ screenPickResolve = null; screenCaptureVideo = null;
+ screenCaptureStream?.getTracks().forEach(track => track.stop()); screenCaptureStream = null;
+ clearTimeout(eyedropperOverlayTimer);
+ if (!disposed) { get('eyedropper').disabled = false; get('eyedropper-overlay').hidden = true; get('eyedropper-overlay').classList.remove('picker-eyedropper-overlay-capture'); get('eyedropper').focus({preventScroll: true}); }
+ }
});
}
const canvas = get('image-canvas');
@@ -521,7 +569,8 @@
window.__esdocColorsCleanup = () => {
disposed = true; imageRequest++; controller.abort();
clearTimeout(eyedropperOverlayTimer);
- shortcuts.close(); get('eyedropper-overlay').hidden = true;
+ screenCaptureStream?.getTracks().forEach(track => track.stop()); screenCaptureStream = null; screenCaptureVideo = null; screenPickResolve = null;
+ shortcuts.close(); get('eyedropper-overlay').hidden = true; get('eyedropper-overlay').classList.remove('picker-eyedropper-overlay-capture');
for (const timer of copyTimers.values()) clearTimeout(timer);
canvas.width = canvas.height = 0;
window.__esdocColorsCleanup = null;
diff --git a/static/js/tools-core.js b/static/js/tools-core.js
index 1a76dc7..425bb12 100644
--- a/static/js/tools-core.js
+++ b/static/js/tools-core.js
@@ -9,6 +9,35 @@
};
const accepts = (path, mode) => (mode !== 'unrpa' && /\.(?:rpym?c|pyc)$/i.test(path))
|| (mode !== 'unrpyc' && /\.rpa$/i.test(path));
+ const archiveName = value => {
+ const name = value.trim().replace(/\.rpa$/i, '') + '.rpa';
+ if (!name || name === '.rpa' || name.length > 255 || /[\\/:*?"<>|\x00-\x1f]/.test(name)
+ || name.startsWith('.') || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])\./i.test(name)) {
+ throw new Error('Укажите имя архива без пути и символов \\ / : * ? " < > |.');
+ }
+ return name;
+ };
+ const packPath = (path, strip = '', prefix = '') => {
+ path = safePath(path);
+ if (strip && !path.startsWith(strip)) throw new Error('Удаляемая папка должна быть общей для всех файлов.');
+ prefix = prefix.trim().replaceAll('\\', '/').replace(/\/$/, '');
+ if (prefix) {
+ try { prefix = safePath(prefix) + '/'; }
+ catch { throw new Error('Укажите относительный путь папки без .., начального / и двоеточия. Например: mods/my_mod.'); }
+ }
+ return safePath(prefix + path.slice(strip.length));
+ };
+ const commonFolders = paths => {
+ if (!paths.length) return [];
+ const parts = safePath(paths[0]).split('/');
+ const folders = [];
+ for (let i = 1; i < parts.length; i++) {
+ const prefix = parts.slice(0, i).join('/') + '/';
+ if (!paths.every(path => path.startsWith(prefix))) break;
+ folders.push(prefix);
+ }
+ return folders;
+ };
const walkEntry = async (entry, parent, add) => {
const path = safePath(parent + entry.name);
if (entry.isFile) {
@@ -110,5 +139,6 @@
const category = pluralRules.select(count);
return `${count.toLocaleString('ru-RU')} ${{ one, few, many }[category] || many}`;
};
- root.ESDocTools = { safePath, accepts, walkEntry, size, explainWarning, explainError, countLabel };
+ root.ESDocTools = { safePath, accepts, walkEntry, size, explainWarning, explainError, countLabel,
+ archiveName, packPath, commonFolders };
})(globalThis);
diff --git a/static/js/tools-worker.js b/static/js/tools-worker.js
index bd94833..f4ec051 100644
--- a/static/js/tools-worker.js
+++ b/static/js/tools-worker.js
@@ -1,6 +1,7 @@
/* All runtime and Python code is served by ES Doc; user Files stay in this
worker. WORKERFS reads Blob slices instead of copying a whole RPA to MEMFS. */
let runtimePromise;
+let packRuntimePromise;
let inputIndices = [];
let browseMounted = false;
let commandQueue = Promise.resolve();
@@ -13,13 +14,19 @@ const checkedFetch = async url => {
return response;
};
-async function initialize(config) {
+async function loadRuntime(config) {
self.postMessage({ type: 'loading', text: 'Загрузка инструментов для локальной обработки…' });
const { loadPyodide } = await import(new URL(config.runtime + 'pyodide.mjs', self.location.origin).href);
const pyodide = await loadPyodide({
indexURL: new URL(config.runtime, self.location.origin).href,
stdout: () => {}, stderr: () => {},
});
+ if (!pyodide.FS.filesystems.WORKERFS) throw new Error('Браузер не поддерживает чтение файлов инструментом. Попробуйте актуальный Firefox, Chrome или Edge.');
+ return pyodide;
+}
+
+async function initialize(config) {
+ const pyodide = await loadRuntime(config);
const [vendor, engine, recovery, syntax, lexer, bytecode, pyc] = await Promise.all([
checkedFetch(config.vendor).then(response => response.arrayBuffer()),
checkedFetch(config.engine).then(response => response.text()),
@@ -52,6 +59,47 @@ import engine
return pyodide;
}
+async function initializePacker(config) {
+ const pyodide = await loadRuntime(config);
+ const packer = await checkedFetch(config.packer).then(response => response.text());
+ pyodide.FS.mkdirTree('/tools');
+ pyodide.FS.writeFile('/tools/rpa.py', packer, { encoding: 'utf8' });
+ await pyodide.runPythonAsync("import sys\nsys.path.insert(0, '/tools')\nimport rpa");
+ return pyodide;
+}
+
+async function pack(data) {
+ let pyodide, mounted = false;
+ try {
+ packRuntimePromise ||= initializePacker(data.config).catch(error => { packRuntimePromise = null; throw error; });
+ pyodide = await packRuntimePromise;
+ pyodide.FS.mkdirTree('/pack');
+ pyodide.FS.mount(pyodide.FS.filesystems.WORKERFS, {
+ blobs: data.files.map((entry, index) => ({ name: `input-${index}`, data: entry.file })),
+ }, '/pack');
+ mounted = true;
+ inputIndices = [];
+ self.postMessage({ type: 'ready' });
+ pyodide.globals.set('pack_json', JSON.stringify(data.files.map((entry, index) => ({
+ path: entry.path, source: `/pack/input-${index}`,
+ }))));
+ self.postMessage({ type: 'output-start' });
+ const result = JSON.parse(await pyodide.runPythonAsync(`
+import json
+from js import esdocNotify, esdocEmit
+pack_result = rpa.pack(json.loads(pack_json), esdocEmit, esdocNotify)
+json.dumps(pack_result)
+`));
+ self.postMessage({ type: 'output', name: data.name, mime: 'application/octet-stream', result });
+ self.postMessage({ type: 'done', result });
+ } catch (error) {
+ self.postMessage({ type: 'fatal', error: String(error.message || error) });
+ } finally {
+ if (mounted) pyodide.FS.unmount('/pack');
+ if (pyodide) await pyodide.runPythonAsync("globals().pop('pack_json', None)\nglobals().pop('pack_result', None)\nimport gc\ngc.collect()");
+ }
+}
+
self.esdocNotify = event => {
const message = event.toJs({ dict_converter: Object.fromEntries });
if (typeof message.index === 'number') message.index = inputIndices[message.index] ?? message.index;
@@ -227,8 +275,8 @@ gc.collect()
}
self.onmessage = ({ data }) => {
- if (!['run', 'catalog', 'source-read', 'browse-list', 'browse-read'].includes(data.type)) return;
- commandQueue = commandQueue.then(() => data.type === 'run' ? run(data)
+ if (!['pack', 'run', 'catalog', 'source-read', 'browse-list', 'browse-read'].includes(data.type)) return;
+ commandQueue = commandQueue.then(() => data.type === 'pack' ? pack(data) : data.type === 'run' ? run(data)
: ['catalog', 'source-read'].includes(data.type) ? source(data) : browse(data))
.catch(error => self.postMessage({ type: 'fatal', error: String(error.message || error) }));
return commandQueue;
diff --git a/static/js/tools.js b/static/js/tools.js
index 4e7e4f4..88e2d34 100644
--- a/static/js/tools.js
+++ b/static/js/tools.js
@@ -2,7 +2,8 @@
window.__esdocToolsCleanup?.();
const app = document.getElementById('tools-app');
if (!app) return;
- const { accepts, safePath, walkEntry, size, explainWarning, explainError, countLabel } = window.ESDocTools;
+ const { accepts, safePath, walkEntry, size, explainWarning, explainError, countLabel,
+ archiveName, packPath, commonFolders } = window.ESDocTools;
const config = JSON.parse(app.dataset.config);
const get = id => document.getElementById('tools-' + id);
const controls = get('controls'), drop = get('drop'), menu = get('add-menu');
@@ -18,9 +19,17 @@
const browserPreview = get('browser-preview'), browserStatus = get('browser-status');
const browserSearch = get('browser-search'), browserCrumbs = get('browser-crumbs');
const errorsPanel = get('errors');
+ const packName = get('pack-name'), packStrip = get('pack-strip'), packPrefix = get('pack-prefix');
+ let mode = new URLSearchParams(window.location.search).get('mode') === 'pack' ? 'pack' : 'unpack';
+ let packValid = true, pathPreviewValid = true;
+ let validPackPaths = new Map(), queuePage = 0, queueMatches = 0;
+ const queuePageSize = 100;
+ const queueSearch = get('queue-search');
+ const queuePrevious = get('queue-previous'), queueNext = get('queue-next');
const controller = new AbortController();
const on = (element, event, callback) => element.addEventListener(event, callback, { signal: controller.signal });
- const files = new Map();
+ const modeFiles = { pack: new Map(), unpack: new Map() };
+ let files = modeFiles[mode];
const rows = new Map();
const supported = Boolean(window.Worker && window.WebAssembly);
let busy = false, enumerating = false, disposed = false, retryAvailable = false;
@@ -109,16 +118,20 @@
const isArchive = entry => /\.rpa$/i.test(entry.path);
const updateControls = () => {
const locked = busy || enumerating || !supported;
- start.disabled = locked || !selected().length;
- start.hidden = !retryAvailable || locked || !selected().length;
+ start.disabled = locked || !selected().length || (mode === 'pack' && !packValid);
+ start.hidden = mode === 'pack' || !retryAvailable || locked || !selected().length;
addButton.disabled = locked;
get('clear').disabled = locked || !selected().length;
for (const field of app.querySelectorAll('.tools-options input')) field.disabled = locked;
+ for (const field of [packName, packStrip, packPrefix, get('mode-pack'), get('mode-unpack')]) field.disabled = locked;
for (const button of list.querySelectorAll('button')) button.disabled = locked;
+ queueSearch.disabled = locked;
+ queuePrevious.disabled = locked || queuePage === 0;
+ queueNext.disabled = locked || (queuePage + 1) * queuePageSize >= queueMatches;
cancel.hidden = !busy && !enumerating;
const cancelHost = busy && task === 'preview' && previewPending ? previewPending : operationActions;
if (cancel.parentNode !== cancelHost) cancelHost.appendChild(cancel);
- downloadAll.disabled = locked || (!selected().length && !outputs.length);
+ downloadAll.disabled = locked || (!selected().length && !outputs.length) || (mode === 'pack' && !packValid);
browseArchive.disabled = locked;
browser.setAttribute('aria-busy', String(busy));
for (const button of browserList.querySelectorAll('button')) button.disabled = locked;
@@ -126,22 +139,37 @@
const render = () => {
list.replaceChildren();
rows.clear();
- const visible = selected();
- queue.hidden = !visible.length;
+ const allFiles = selected();
+ const query = queueSearch.value.trim().normalize('NFC').toLowerCase();
+ const matched = mode === 'pack' && query ? allFiles.filter(entry =>
+ [entry.path, validPackPaths.get(entry.path) || ''].some(path => path.normalize('NFC').toLowerCase().includes(query))) : allFiles;
+ queueMatches = matched.length;
+ queuePage = Math.min(queuePage, Math.max(0, Math.ceil(queueMatches / queuePageSize) - 1));
+ const first = mode === 'pack' ? queuePage * queuePageSize : 0;
+ const visible = matched.slice(first, first + (mode === 'pack' ? queuePageSize : 200));
+ queue.hidden = !allFiles.length;
list.hidden = false;
- get('count').textContent = `${countLabel(visible.length, 'файл', 'файла', 'файлов')} / ${size(visible.reduce((total, entry) => total + entry.file.size, 0))}`;
+ get('count').textContent = `${countLabel(allFiles.length, 'файл', 'файла', 'файлов')} / ${size(allFiles.reduce((total, entry) => total + entry.file.size, 0))}`;
+ get('queue-tools').hidden = mode !== 'pack' || !allFiles.length;
+ get('pack-preview-note').hidden = pathPreviewValid;
+ get('queue-pages').hidden = queueMatches <= queuePageSize;
+ get('queue-range').textContent = queueMatches ? `${first + 1}–${first + visible.length} из ${queueMatches}` : 'Файлы не найдены. Измените поиск.';
const fragment = document.createDocumentFragment();
// Bound the DOM for folders with thousands of scripts; all queued files
// are still processed even when their rows are not rendered.
- for (const entry of visible.slice(0, 200)) {
+ for (const entry of visible) {
const row = document.createElement('li');
const name = document.createElement('div');
name.className = 'tools-file-name';
- const open = document.createElement('button');
+ const open = document.createElement(mode === 'pack' ? 'span' : 'button');
open.type = 'button';
open.className = 'tools-text-button';
- open.textContent = entry.path;
+ if (mode === 'pack') {
+ open.textContent = validPackPaths.get(entry.path) || 'Путь не рассчитан';
+ open.className = 'tools-pack-path';
+ } else open.textContent = entry.path;
open.addEventListener('click', () => {
+ if (mode === 'pack') return;
browseArchive.value = isArchive(entry) ? String(catalogFiles.findIndex(item => item.path === entry.path)) : 'scripts';
openBrowse();
if (!isArchive(entry)) {
@@ -152,7 +180,8 @@
name.appendChild(open);
const state = document.createElement('span');
state.className = 'tools-file-state';
- state.textContent = 'Добавлен';
+ state.textContent = mode === 'pack' && !validPackPaths.has(entry.path) ? 'Исходный файл: ' + entry.path
+ : mode === 'pack' && open.textContent !== entry.path ? 'Из ' + entry.path : 'Добавлен';
name.appendChild(state);
const bytes = document.createElement('span');
bytes.className = 'tools-file-size';
@@ -162,14 +191,28 @@
remove.className = 'tools-remove';
remove.setAttribute('aria-label', 'Убрать ' + entry.path);
remove.innerHTML = window.ESDocIcons.svg('x', 16);
- remove.addEventListener('click', () => { files.delete(entry.path); clearResult(); retryAvailable = false; render(); loadCatalog(); });
+ remove.addEventListener('click', () => {
+ const focused = document.activeElement === remove;
+ const index = visible.indexOf(entry);
+ files.delete(entry.path);
+ validPackPaths.delete(entry.path);
+ clearResult();
+ retryAvailable = false;
+ if (mode === 'pack') refreshPackFolders();
+ render();
+ loadCatalog();
+ if (focused) {
+ const buttons = list.querySelectorAll('.tools-remove');
+ (buttons[Math.min(index, buttons.length - 1)] || addButton).focus();
+ }
+ });
row.append(name, bytes, remove);
- rows.set(entry.path, state);
+ rows.set(open.textContent, state);
fragment.appendChild(row);
}
- if (visible.length > 200) {
+ if (mode !== 'pack' && allFiles.length > 200) {
const remainder = document.createElement('li');
- remainder.textContent = `И ещё ${countLabel(visible.length - 200, 'файл', 'файла', 'файлов')}. Будут обработаны все.`;
+ remainder.textContent = `И ещё ${countLabel(allFiles.length - 200, 'файл', 'файла', 'файлов')}. Будут обработаны все.`;
fragment.appendChild(remainder);
}
list.appendChild(fragment);
@@ -189,7 +232,7 @@
await collect(entry => {
if (disposed || id !== operation) throw new Error('Добавление отменено.');
entry.path = safePath(entry.path);
- if (!accepts(entry.path, 'combined')) { skipped++; return; }
+ if (mode !== 'pack' && !accepts(entry.path, 'combined')) { skipped++; return; }
if (files.has(entry.path)) { duplicates++; return; }
files.set(entry.path, entry);
added++;
@@ -197,12 +240,13 @@
if (id === operation) say(`Добавлено файлов: ${added}.`
+ (skipped ? ` Другие форматы пропущены: ${skipped}.` : '')
+ (duplicates ? ` Повторные пути пропущены: ${duplicates}.` : '')
- + (!files.size ? ' Выберите .rpyc, .rpymc, .pyc или .rpa.' : ''));
+ + (!files.size ? (mode === 'pack' ? ' Выберите ресурсы для архива.' : ' Выберите .rpyc, .rpymc, .pyc или .rpa.') : ''));
} catch (error) {
if (id === operation) showErrors([error.message]);
} finally {
if (!disposed && id === operation) {
enumerating = false;
+ if (mode === 'pack') refreshPackFolders();
render();
if (added) loadCatalog();
}
@@ -246,8 +290,15 @@
resetWorker();
retryAvailable = false;
progress.hidden = true;
+ if (mode === 'pack') {
+ validPackPaths.clear();
+ queueSearch.value = '';
+ queuePage = 0;
+ refreshPackFolders();
+ }
say('Добавленные файлы удалены.');
render();
+ addButton.focus();
});
const resetWorker = () => {
@@ -270,10 +321,96 @@
start.classList.remove('tools-button-primary');
downloadAll.hidden = false;
downloadAll.innerHTML = ''
- + (exportReady ? 'Скачать ещё раз' : 'Скачать всё в ZIP') + '';
- if (outputs.length === 1) outputs[0].link.classList.add('tools-button-primary');
+ + (exportReady ? 'Скачать ещё раз' : mode === 'pack' ? 'Создать и скачать RPA' : 'Скачать всё в ZIP') + '';
+ if (outputs.length === 1 && mode !== 'pack') outputs[0].link.classList.add('tools-button-primary');
};
const options = () => ({ try_harder: get('try-harder').checked, no_init_offset: get('no-init-offset').checked });
+ const validatePack = () => {
+ let errorMessage = '';
+ packName.removeAttribute('aria-invalid');
+ packPrefix.removeAttribute('aria-invalid');
+ try {
+ archiveName(packName.value);
+ } catch (error) {
+ errorMessage = error.message;
+ packName.setAttribute('aria-invalid', 'true');
+ }
+ let prefixValid = false;
+ try {
+ packPath('resource', '', packPrefix.value);
+ prefixValid = true;
+ const paths = new Set();
+ const mapped = new Map();
+ for (const entry of selected()) {
+ const path = packPath(entry.path, packStrip.value, packPrefix.value);
+ const canonical = path.normalize('NFC').toLowerCase();
+ if (paths.has(canonical)) throw new Error(`Повторный путь в архиве: ${path}. Уберите один из файлов.`);
+ paths.add(canonical);
+ mapped.set(entry.path, path);
+ }
+ validPackPaths = mapped;
+ pathPreviewValid = true;
+ } catch (error) {
+ pathPreviewValid = false;
+ if (!prefixValid) packPrefix.setAttribute('aria-invalid', 'true');
+ errorMessage ||= error.message;
+ }
+ packValid = !errorMessage;
+ get('pack-error').textContent = errorMessage;
+ get('pack-error').hidden = packValid;
+ return packValid;
+ };
+ const refreshPackFolders = () => {
+ const previous = packStrip.value;
+ const initial = packStrip.children.length <= 1;
+ const folders = commonFolders(selected().map(entry => entry.path));
+ packStrip.replaceChildren();
+ for (const prefix of ['', ...folders]) {
+ const option = document.createElement('option');
+ option.value = prefix;
+ option.textContent = prefix || 'Ничего не удалять';
+ packStrip.appendChild(option);
+ }
+ packStrip.value = initial && folders.includes('game/') ? 'game/'
+ : folders.includes(previous) ? previous : '';
+ validatePack();
+ };
+ const applyMode = () => {
+ get('mode-pack').checked = mode === 'pack';
+ get('mode-unpack').checked = mode !== 'pack';
+ get('pack-settings').hidden = mode !== 'pack';
+ get('options').hidden = mode === 'pack';
+ get('pack-help').hidden = mode !== 'pack';
+ get('unpack-help').hidden = mode === 'pack';
+ get('queue-heading').textContent = mode === 'pack' ? 'Пути внутри архива' : 'Добавленные файлы';
+ list.setAttribute('aria-label', mode === 'pack' ? 'Пути внутри архива' : 'Добавленные файлы');
+ get('drop-help').textContent = mode === 'pack' ? 'Изображения, музыка, шрифты и другие файлы' : '.rpa, .rpyc, .rpymc, .pyc';
+ fileInput.accept = mode === 'pack' ? '' : '.rpa,.rpyc,.rpymc,.pyc';
+ if (mode === 'pack') validatePack();
+ };
+ on(queueSearch, 'input', () => { queuePage = 0; render(); });
+ on(queuePrevious, 'click', () => { queuePage--; render(); });
+ on(queueNext, 'click', () => { queuePage++; render(); });
+ for (const field of [packName, packPrefix, packStrip]) on(field, field === packStrip ? 'change' : 'input', () => {
+ clearResult();
+ validatePack();
+ render();
+ if (selected().length) loadCatalog();
+ });
+ for (const value of ['pack', 'unpack']) on(get('mode-' + value), 'change', () => {
+ if (busy || enumerating || mode === value) return;
+ mode = value;
+ files = modeFiles[mode];
+ resetWorker();
+ clearResult();
+ activeFiles = [];
+ retryAvailable = false;
+ fileInput.value = folderInput.value = '';
+ applyMode();
+ render();
+ if (files.size) loadCatalog();
+ else say(mode === 'pack' ? 'Добавьте файлы для нового RPA-архива.' : 'Добавьте архивы или скомпилированные скрипты.');
+ });
const readBrowse = entry => {
if (busy) return;
retryAvailable = false;
@@ -740,6 +877,11 @@
if (data.type === 'loading') say(data.text);
else if (data.type === 'ready') { say('Инструменты готовы. Обработка файлов…'); processedFiles = 0; progressBar.max = activeFiles.length; progressBar.value = 0; }
else if (data.type === 'chunk') chunks.push(new Blob([data.buffer]));
+ else if (data.type === 'pack-progress') {
+ current.textContent = `${data.path} / ${size(data.current)} из ${size(data.total)}`;
+ progressBar.max = data.total || 1;
+ progressBar.value = data.current;
+ }
else if (data.type === 'entry') {
current.textContent = `${data.path} / ${data.current} из ${data.total}`;
progressBar.value = processedFiles + data.current / data.total;
@@ -751,11 +893,11 @@
state.classList.toggle('is-error', data.state === 'error');
}
current.textContent = data.path;
- if (data.state !== 'working') progressBar.value = ++processedFiles;
+ if (data.state !== 'working' && mode !== 'pack') progressBar.value = ++processedFiles;
} else if (data.type === 'output-start') chunks = [];
else if (data.type === 'output') {
if (data.result.written) {
- const blob = new Blob(chunks, { type: 'application/zip' });
+ const blob = new Blob(chunks, { type: data.mime || 'application/zip' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.className = 'tools-button';
@@ -782,13 +924,14 @@
const result = data.result;
chunks = [];
retryAvailable = false;
- clearOnDownload = result.failed === 0;
+ clearOnDownload = result.failed === 0 && mode !== 'pack';
exportReady = result.failed === 0;
finish();
+ if (mode === 'pack') render();
resultPanel.hidden = false;
if (outputs.length) {
showOutputs();
- get('result-description').textContent = `Обработано: ${result.succeeded}.`
+ get('result-description').textContent = (mode === 'pack' ? `Файлов в RPA: ${result.written}.` : `Обработано: ${result.succeeded}.`)
+ (result.written !== result.succeeded ? ` Файлов в результате: ${result.written}.` : '')
+ (result.failed ? ` Ошибок: ${result.failed}.` : '');
renderWarnings(warningsPanel, get('warnings-summary'), warningList,
@@ -819,6 +962,15 @@
};
const loadCatalog = () => {
if (!supported || busy || enumerating || !selected().length) return;
+ if (mode === 'pack') {
+ refreshPackFolders();
+ validatePack();
+ resultPanel.hidden = false;
+ downloadAll.hidden = false;
+ showOutputs();
+ updateControls();
+ return;
+ }
catalogFiles = selected();
retryAvailable = false;
const archives = catalogFiles.map((entry, source) => ({ entry, source })).filter(({ entry }) => isArchive(entry));
@@ -862,6 +1014,7 @@
};
const processFiles = () => {
if (!supported || busy || enumerating || !selected().length) return;
+ if (mode === 'pack' && !validatePack()) return;
for (const output of outputs) URL.revokeObjectURL(output.url);
outputs.length = 0;
downloads.replaceChildren();
@@ -880,8 +1033,9 @@
say('Подготовка к обработке…');
try {
ensureWorker();
- worker.postMessage({ type: 'run', config, mode: 'combined', files: activeFiles,
- options: options() });
+ if (mode === 'pack') worker.postMessage({ type: 'pack', config, name: archiveName(packName.value),
+ files: activeFiles.map(entry => ({ file: entry.file, path: packPath(entry.path, packStrip.value, packPrefix.value) })) });
+ else worker.postMessage({ type: 'run', config, mode: 'combined', files: activeFiles, options: options() });
} catch (error) { fail(error.message); }
};
on(start, 'click', loadCatalog);
@@ -942,7 +1096,10 @@
controller.abort();
resetWorker();
clearResult();
- files.clear();
+ modeFiles.pack.clear();
+ modeFiles.unpack.clear();
+ validPackPaths.clear();
+ activeFiles = [];
window.__esdocToolsCleanup = null;
};
if (!supported) {
@@ -952,6 +1109,6 @@
return;
}
controls.hidden = false;
- get('drop-help').textContent = '.rpa, .rpyc, .rpymc, .pyc';
+ applyMode();
render();
})();
diff --git a/static/tools/rpa.py b/static/tools/rpa.py
new file mode 100644
index 0000000..1fd61fb
--- /dev/null
+++ b/static/tools/rpa.py
@@ -0,0 +1,74 @@
+"""Stream RPA-3.0 resources with a Python 2 compatible index.
+
+Format reference: renpy/renpy launcher/game/archiver.rpy and renpy/loader.py.
+Files are copied unchanged; only the index is compressed.
+"""
+import os
+import pickle
+import unicodedata
+import zlib
+
+CHUNK = 256 * 1024
+KEY = 0x42424242
+HEADER_SIZE = 34
+
+
+def safe_path(value):
+ if not isinstance(value, str):
+ raise ValueError('Путь файла должен быть строкой.')
+ value = value.replace('\\', '/')
+ if (not value or value.startswith('/') or ':' in value
+ or any(ord(c) < 32 for c in value)
+ or any(part in ('', '.', '..') for part in value.split('/'))
+ or len(value) > 4096):
+ raise ValueError(f'Недопустимый путь: {value!r}.')
+ return value
+
+
+def pack(files, emit, notify=lambda event: None):
+ """Validate all paths/sizes before emitting a forward-only archive stream."""
+ if not files:
+ raise ValueError('Добавьте файлы для упаковки.')
+ entries = []
+ names = set()
+ for entry in files:
+ path = safe_path(entry['path'])
+ canonical = unicodedata.normalize('NFC', path).lower()
+ if canonical in names:
+ raise ValueError(f'Повторный путь в архиве: {path}. Измените пути или уберите файл.')
+ names.add(canonical)
+ entries.append((path, entry['source'], os.path.getsize(entry['source'])))
+ for path, _, _ in entries:
+ parts = unicodedata.normalize('NFC', path).lower().split('/')
+ if any('/'.join(parts[:i]) in names for i in range(1, len(parts))):
+ raise ValueError(f'Папка совпадает с именем файла: {path}. Измените пути.')
+ entries.sort(key=lambda entry: entry[0])
+ offset = HEADER_SIZE
+ index = {}
+ for path, _, length in entries:
+ # Two-element records need no byte-string pickle globals on Python 2.
+ index[path] = [(offset ^ KEY, length ^ KEY)]
+ offset += length
+ compressed_index = zlib.compress(pickle.dumps(index, protocol=2))
+ emit(f'RPA-3.0 {offset:016x} {KEY:08x}\n'.encode('ascii'))
+ total_bytes = offset - HEADER_SIZE
+ copied = 0
+ for path, source, length in entries:
+ notify({'type': 'file', 'path': path, 'state': 'working'})
+ remaining = length
+ with open(source, 'rb') as resource:
+ while remaining:
+ data = resource.read(min(CHUNK, remaining))
+ if not data:
+ raise ValueError(f'Не удалось дочитать файл: {path}. Добавьте его заново.')
+ emit(data)
+ copied += len(data)
+ remaining -= len(data)
+ notify({'type': 'pack-progress', 'path': path, 'current': copied, 'total': total_bytes})
+ if resource.read(1):
+ raise ValueError(f'Размер файла изменился: {path}. Добавьте его заново.')
+ notify({'type': 'file', 'path': path, 'state': 'done'})
+ for start in range(0, len(compressed_index), CHUNK):
+ emit(compressed_index[start:start + CHUNK])
+ return {'succeeded': len(entries), 'written': len(entries), 'failed': 0,
+ 'warnings': 0, 'warning_details': [], 'errors': []}
diff --git a/templates/tools.html b/templates/tools.html
index 1e2860a..14d9898 100644
--- a/templates/tools.html
+++ b/templates/tools.html
@@ -14,7 +14,7 @@
diff --git a/tests/rpa_browser.cjs b/tests/rpa_browser.cjs
index 8bac164..92f6c0a 100644
--- a/tests/rpa_browser.cjs
+++ b/tests/rpa_browser.cjs
@@ -6,13 +6,13 @@ const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { chromium } = require('playwright');
const root = path.resolve(__dirname, '..');
-const fixtures = path.join(root, 'temp/rpa-browser/game');
+const fixtures = path.join(root, 'temp/rpa-tree-browser/game');
const target = process.env.RPA_TEST_URL || 'http://127.0.0.1:8012/tools/unpack?mode=pack';
const resources = {
- 'mods/my_mod/images/bg.png': Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'),
- 'mods/my_mod/audio/тема.ogg': Buffer.from([0, 255, 128, 65]),
- 'mods/my_mod/empty.txt': Buffer.alloc(0),
- 'mods/my_mod/script.rpy': Buffer.from('label my_mod_start:\n return\n'),
+ 'my_mod/images/bg.png': Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'),
+ 'my_mod/audio/тема.ogg': Buffer.from([0, 255, 128, 65]),
+ 'my_mod/empty.txt': Buffer.alloc(0),
+ 'my_mod/script.rpy': Buffer.from('label my_mod_start:\n return\n'),
};
for (const [name, data] of Object.entries(resources)) {
const file = path.join(fixtures, name);
@@ -39,23 +39,29 @@ for (const [name, data] of Object.entries(resources)) {
element.getAttribute('aria-describedby').split(/\s+/).filter(id => !document.getElementById(id))));
assert.deepEqual(missingDescriptions, []);
assert.equal(await page.locator('#tools-pack-strip').inputValue(), 'game/');
- assert.match(await page.locator('#tools-file-list').textContent(), /my_mod\/images\/bg.png/);
- const verifiedPaths = await page.locator('.tools-pack-path').allTextContents();
+ assert(await page.locator('#tools-browser').isVisible());
+ await page.locator('#tools-browser-list button').filter({hasText: 'my_mod/'}).click();
+ await page.locator('#tools-browser-list button').filter({hasText: 'images/'}).click();
+ await page.locator('#tools-browser-list button').filter({hasText: 'bg.png'}).click();
+ assert(await page.locator('#tools-browser-preview img').isVisible());
+ await page.locator('#tools-browser-search').fill('/');
+ const verifiedPaths = await page.locator('#tools-browser-list button[data-entry-id]').allTextContents();
await page.locator('#tools-pack-prefix').fill('../outside');
assert(await page.locator('#tools-download-all').isDisabled());
assert(await page.locator('#tools-pack-error').isVisible());
assert.match(await page.locator('#tools-pack-prefix-error').textContent(), /относительный путь.*my_mod/);
assert(await page.locator('#tools-pack-preview-note').isVisible());
- assert.deepEqual(await page.locator('.tools-pack-path').allTextContents(), verifiedPaths);
+ assert.deepEqual(await page.locator('#tools-browser-list button[data-entry-id]').allTextContents(), verifiedPaths);
await page.locator('#tools-pack-prefix').fill('');
await page.locator('#tools-pack-name').fill('my_mod');
await page.locator('#tools-mode-unpack').check();
assert(!await page.locator('#tools-queue').isVisible());
await page.locator('#tools-mode-pack').check();
- assert.deepEqual(await page.locator('.tools-pack-path').allTextContents(), verifiedPaths);
+ await page.locator('#tools-browser-search').fill('/');
+ assert.deepEqual(await page.locator('#tools-browser-list button[data-entry-id]').allTextContents(), verifiedPaths);
assert.equal(await page.locator('#tools-pack-name').inputValue(), 'my_mod');
- await page.locator('#tools-queue-search').fill('bg.png');
- assert.equal(await page.locator('.tools-pack-path').count(), 1);
+ await page.locator('#tools-browser-search').fill('bg.png');
+ assert.equal(await page.locator('#tools-browser-list button[data-entry-id]').count(), 1);
// Cancel lazy startup and retry from the same File objects.
await page.locator('#tools-download-all').click();
@@ -70,10 +76,10 @@ for (const [name, data] of Object.entries(resources)) {
await saved.saveAs(archive);
assert.match(await page.locator('#tools-result-description').textContent(), /Файлов в RPA: 4/);
assert.equal(await page.locator('#tools-queue').isVisible(), true);
- await page.locator('#tools-queue-search').fill('');
- assert.deepEqual(await page.locator('.tools-pack-path').allTextContents(), verifiedPaths);
+ await page.locator('#tools-browser-search').fill('/');
+ assert.deepEqual(await page.locator('#tools-browser-list button[data-entry-id]').allTextContents(), verifiedPaths);
await page.locator('#tools-pack-prefix').fill('alternate');
- assert.equal(await page.locator('.tools-pack-path').first().textContent(), 'alternate/' + verifiedPaths[0]);
+ assert.equal(await page.locator('#tools-browser-list button[data-entry-id]').first().textContent(), 'alternate/' + verifiedPaths[0]);
assert(await page.locator('#tools-download-all').isEnabled());
await page.locator('#tools-pack-prefix').fill('');
const python = path.join(root, '.venv/Scripts/python.exe');
@@ -88,6 +94,11 @@ for (const [name, data] of Object.entries(resources)) {
' assert content == (pathlib.Path(sys.argv[2]) / name).read_bytes(), name',
'assert len(index) == 4',
].join('\n'), archive, fixtures]);
+ await page.locator('#tools-browser-search').fill('script.rpy');
+ await page.locator('#tools-browser-list button[data-entry-id]').click();
+ await page.locator('#tools-browser-preview pre .k').first().waitFor({state: 'visible', timeout: 90000});
+ assert.match(await page.locator('#tools-browser-preview code').textContent(), /label my_mod_start/);
+ await page.locator('#tools-browser-search').fill('/');
assert(requests.every(([method]) => method === 'GET'), JSON.stringify(requests));
assert(!requests.some(([, url]) => /vendor\.zip|bytecode\.zip/.test(url)), 'Packing must not load decompilers');
@@ -96,7 +107,8 @@ for (const [name, data] of Object.entries(resources)) {
await page.locator('#tools-files').setInputFiles(archive);
await page.locator('#tools-browser-list button').first().waitFor({ state: 'visible', timeout: 90000 });
await page.locator('#tools-mode-pack').check();
- assert.deepEqual(await page.locator('.tools-pack-path').allTextContents(), verifiedPaths);
+ await page.locator('#tools-browser-search').fill('/');
+ assert.deepEqual(await page.locator('#tools-browser-list button[data-entry-id]').allTextContents(), verifiedPaths);
await page.locator('#tools-mode-unpack').check();
await page.locator('#tools-browser-list button').first().waitFor({ state: 'visible', timeout: 90000 });
await page.locator('#tools-browser-search').fill('тема');
@@ -122,22 +134,22 @@ for (const [name, data] of Object.entries(resources)) {
await page.locator('#tools-files').setInputFiles(Array.from({ length: 251 }, (_, i) => ({
name: `item-${String(i).padStart(3, '0')}.bin`, mimeType: 'application/octet-stream', buffer: Buffer.from([i % 256]),
})));
- assert.equal(await page.locator('.tools-pack-path').count(), 100);
+ assert.equal(await page.locator('#tools-browser-list button[data-entry-id]').count(), 100);
await page.locator('#tools-queue-next').click();
await page.locator('#tools-queue-next').click();
- assert.equal(await page.locator('.tools-pack-path').count(), 51);
+ assert.equal(await page.locator('#tools-browser-list button[data-entry-id]').count(), 51);
assert.match(await page.locator('#tools-queue-range').textContent(), /201–251 из 251/);
- await page.locator('#tools-queue-search').fill('item-250');
- await page.locator('.tools-remove').focus();
+ await page.locator('#tools-browser-search').fill('item-250');
+ await page.locator('#tools-browser-list .tools-remove-source').focus();
await page.keyboard.press('Enter');
assert(await page.locator('#tools-add').evaluate(el => el === document.activeElement));
- await page.locator('#tools-queue-search').fill('');
- await page.locator('.tools-remove').first().focus();
+ await page.locator('#tools-browser-search').fill('');
+ await page.locator('#tools-browser-list .tools-remove-source').first().focus();
await page.keyboard.press('Enter');
- assert(await page.locator('.tools-remove').first().evaluate(el => el === document.activeElement));
+ assert(await page.locator('#tools-browser-list .tools-remove-source').first().evaluate(el => el === document.activeElement));
await page.locator('#tools-clear').click();
await page.locator('#tools-files').setInputFiles({ name: 'last.txt', mimeType: 'text/plain', buffer: Buffer.from('last') });
- await page.locator('.tools-remove').focus();
+ await page.locator('#tools-browser-list .tools-remove-source').focus();
await page.keyboard.press('Enter');
assert(await page.locator('#tools-add').evaluate(el => el === document.activeElement));
diff --git a/tests/tools_ui.test.mjs b/tests/tools_ui.test.mjs
index 08e3543..65cfad6 100644
--- a/tests/tools_ui.test.mjs
+++ b/tests/tools_ui.test.mjs
@@ -25,7 +25,7 @@ function page(search = '', withClipboard = false) {
const matches = [];
const visit = element => {
for (const child of element.children) {
- if (selector === '.tools-remove' && child.className === 'tools-remove') matches.push(child);
+ if ((selector === '.tools-remove' && child.className === 'tools-remove') || (selector === '.tools-remove-source' && child.className?.includes('tools-remove-source'))) matches.push(child);
if (selector === 'button' && child.tagName === 'BUTTON') matches.push(child);
visit(child);
}
@@ -116,8 +116,7 @@ test('packing accepts resources and sends paths relative to game without catalog
await Promise.resolve();
assert.equal(ui.messages.length, 0);
assert.equal(ui.get('pack-strip').value, 'game/');
- assert.equal(ui.get('file-list').children[0].children[0].children[0].children[0].textContent,
- 'mods/demo/images/bg.png');
+ assert.equal(ui.get('browser-list').children[0].children[0].children[1].textContent, 'mods/');
ui.get('download-all').click();
assert.equal(ui.messages.at(-1).type, 'pack');
assert.equal(ui.messages.at(-1).name, 'demo.rpa');
@@ -135,6 +134,61 @@ test('packing accepts resources and sends paths relative to game without catalog
assert.equal(ui.messages.at(-1).files[0].path, 'next/mods/demo/images/bg.png');
});
+test('packing browses folders and previews original resources without decompilation', async () => {
+ const ui = page('?mode=pack');
+ ui.get('pack-name').value = 'demo';
+ ui.get('pack-prefix').value = 'my_mod/images';
+ await ui.add(['bg.png']);
+ assert.equal(ui.get('browser').hidden, false);
+ assert.equal(ui.get('file-list').hidden, true);
+ const first = () => ui.get('browser-list').children[0].children[0];
+ assert.equal(first().children[1].textContent, 'my_mod/');
+ first().click();
+ assert.equal(first().children[1].textContent, 'images/');
+ first().click();
+ assert.equal(first().children[1].textContent, 'bg.png');
+ first().click();
+ assert.equal(ui.get('browser-preview').children[1].tagName, 'IMG');
+ assert.equal(ui.messages.length, 0);
+});
+
+test('packing text previews request syntax highlighting of the original file', async () => {
+ const ui = page('?mode=pack');
+ ui.get('pack-name').value = 'demo';
+ await ui.add(['script.rpy']);
+ ui.get('browser-list').children[0].children[0].click();
+ assert.equal(ui.messages.at(-1).type, 'pack-preview');
+ assert.equal(ui.messages.at(-1).name, 'script.rpy');
+ ui.send({ type: 'source-file', requestId: ui.messages.at(-1).requestId,
+ name: 'script.rpy', buffer: new TextEncoder().encode('label start:\n return').buffer,
+ html: '
label start:', warnings: [] });
+ assert.equal(ui.get('browser-preview').attributes['aria-busy'], 'false');
+ assert.equal(ui.get('download-all').disabled, false);
+});
+
+test('removing the last packing file returns keyboard focus to adding files', async () => {
+ const ui = page('?mode=pack');
+ ui.get('pack-name').value = 'demo';
+ await ui.add(['last.txt']);
+ const remove = ui.get('browser-list').querySelectorAll('.tools-remove-source')[0];
+ remove.focus(); remove.click();
+ assert.equal(ui.get('browser').hidden, true);
+ assert.equal(ui.document.activeElement, ui.get('add'));
+});
+
+test('packing previews keep compiled files intact and do not start decompilers', async () => {
+ const ui = page('?mode=pack');
+ ui.get('pack-name').value = 'demo';
+ await ui.add(['script.rpyc']);
+ ui.get('browser-list').children[0].children[0].click();
+ assert.equal(ui.messages.length, 0);
+ const preview = ui.get('browser-preview');
+ assert.equal(preview.children.at(-1).download, 'script.rpyc');
+ ui.get('download-all').click();
+ assert.equal(ui.messages.at(-1).type, 'pack');
+ assert.equal(ui.messages.at(-1).files[0].path, 'script.rpyc');
+});
+
test('mode switches retain separate queues and packing settings', async () => {
const ui = page('?mode=pack');
ui.get('pack-name').value = 'demo';
@@ -160,26 +214,26 @@ test('packing pagination and search reach all files without filtering export', a
ui.get('pack-name').value = 'demo';
ui.get('pack-name').emit('input');
await ui.add(Array.from({ length: 251 }, (_, i) => `item-${String(i).padStart(3, '0')}.bin`));
- const rows = () => ui.get('file-list').children[0].children;
+ const rows = () => ui.get('browser-list').children;
assert.equal(rows().length, 100);
ui.get('queue-next').click();
ui.get('queue-next').click();
assert.equal(rows().length, 51);
assert.equal(ui.get('queue-range').textContent, '201–251 из 251');
- ui.get('queue-search').value = 'item-250';
- ui.get('queue-search').emit('input');
+ ui.get('browser-search').value = 'item-250';
+ ui.get('browser-search').emit('input');
assert.equal(rows().length, 1);
ui.get('download-all').click();
assert.equal(ui.messages.at(-1).files.length, 251);
ui.exportDone(['demo.rpa']);
- const remove = ui.get('file-list').querySelectorAll('.tools-remove')[0];
+ const remove = ui.get('browser-list').querySelectorAll('.tools-remove-source')[0];
remove.focus(); remove.click();
assert.equal(ui.document.activeElement, ui.get('add'));
- ui.get('queue-search').value = '';
- ui.get('queue-search').emit('input');
- const first = ui.get('file-list').querySelectorAll('.tools-remove')[0];
+ ui.get('browser-search').value = '';
+ ui.get('browser-search').emit('input');
+ const first = ui.get('browser-list').querySelectorAll('.tools-remove-source')[0];
first.focus(); first.click();
- assert.equal(ui.document.activeElement, ui.get('file-list').querySelectorAll('.tools-remove')[0]);
+ assert.equal(ui.document.activeElement, ui.get('browser-list').querySelectorAll('.tools-remove-source')[0]);
ui.get('clear').click();
assert.equal(ui.get('queue').hidden, true);
assert.equal(ui.document.activeElement, ui.get('add'));
@@ -191,7 +245,9 @@ test('an invalid prefix preserves the verified path preview and explains how to
ui.get('pack-prefix').value = 'mods/demo';
ui.get('pack-prefix').emit('input');
await ui.add(['bg.png']);
- const path = () => ui.get('file-list').children[0].children[0].children[0].children[0].textContent;
+ ui.get('browser-search').value = 'bg.png';
+ ui.get('browser-search').emit('input');
+ const path = () => ui.get('browser-list').children[0].children[0].children[1].textContent;
assert.equal(path(), 'mods/demo/bg.png');
ui.get('pack-prefix').value = '../bad';
ui.get('pack-prefix').emit('input');
From 281f8eea2a2322ca6c8143aac99f22e5bf4aeb96 Mon Sep 17 00:00:00 2001
From: somepoi <46904988+somepoi@users.noreply.github.com>
Date: Sat, 10 Oct 2026 01:07:36 +0300
Subject: [PATCH 05/27] feature: added ability to remove folders while packing
rpa
---
static/js/tools.js | 55 ++++++++++++++++++++++++-----------------
tests/rpa_browser.cjs | 20 ++++++++++++++-
tests/tools_ui.test.mjs | 38 ++++++++++++++++++++++++++++
3 files changed, 90 insertions(+), 23 deletions(-)
diff --git a/static/js/tools.js b/static/js/tools.js
index e6330de..04a7406 100644
--- a/static/js/tools.js
+++ b/static/js/tools.js
@@ -549,8 +549,40 @@
const visibleFolders = mode === 'pack' ? folderNames.slice(offset, offset + pageSize) : folderNames;
const visibleEntries = entries.slice(mode === 'pack' ? Math.max(0, offset - folderNames.length) : 0,
mode === 'pack' ? Math.max(0, offset + pageSize - folderNames.length) : 500);
+ const addRemove = (originalPaths, label, title) => {
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.className = 'tools-icon-button tools-remove-source';
+ remove.title = title;
+ remove.setAttribute('aria-label', label);
+ remove.innerHTML = '
';
+ remove.disabled = busy;
+ remove.addEventListener('click', () => {
+ if (busy || enumerating) return;
+ const focused = document.activeElement === remove;
+ const index = Array.from(browserList.querySelectorAll('.tools-remove-source')).indexOf(remove);
+ for (const originalPath of originalPaths) {
+ files.delete(originalPath);
+ if (mode === 'pack') validPackPaths.delete(originalPath);
+ }
+ clearResult();
+ if (mode === 'pack') refreshPackFolders();
+ render();
+ loadCatalog();
+ if (focused && mode === 'pack') {
+ const buttons = files.size ? browserList.querySelectorAll('.tools-remove-source') : [];
+ (buttons[Math.min(index, buttons.length - 1)] || addButton).focus();
+ }
+ });
+ browserList.lastElementChild.appendChild(remove);
+ };
for (const folder of visibleFolders) {
row(folder + '/', 'папка', () => { browseFolder += folder + '/'; queuePage = 0; renderBrowse(); }, 'folder');
+ if (mode === 'pack') {
+ const folderPath = browseFolder + folder + '/';
+ const originalPaths = browseEntries.filter(entry => entry.path.startsWith(folderPath)).map(entry => entry.originalPath);
+ addRemove(originalPaths, 'Убрать папку ' + folderPath + ' и все вложенные файлы', 'Убрать папку и все вложенные файлы');
+ }
}
for (const entry of visibleEntries) {
const kind = /\.(rpyc|rpymc|pyc|rpy|rpym|py|js|json|css|txt)$/i.test(entry.path) ? 'code'
@@ -559,28 +591,7 @@
row(query ? entry.path : entry.path.slice(browseFolder.length), size(entry.size), () => readBrowse(entry), kind,
selectedEntry?.id === entry.id, entry.id);
if (mode === 'pack' || catalogFiles[entry.source]?.path === entry.path && !isArchive(catalogFiles[entry.source]) && files.has(entry.path)) {
- const remove = document.createElement('button');
- remove.type = 'button';
- remove.className = 'tools-icon-button tools-remove-source';
- remove.title = 'Убрать файл';
- remove.setAttribute('aria-label', 'Убрать ' + entry.path);
- remove.innerHTML = '
';
- remove.disabled = busy;
- remove.addEventListener('click', () => {
- const focused = document.activeElement === remove;
- const index = Array.from(browserList.querySelectorAll('.tools-remove-source')).indexOf(remove);
- files.delete(mode === 'pack' ? entry.originalPath : entry.path);
- if (mode === 'pack') validPackPaths.delete(entry.originalPath);
- clearResult();
- if (mode === 'pack') refreshPackFolders();
- render();
- loadCatalog();
- if (focused && mode === 'pack') {
- const buttons = files.size ? browserList.querySelectorAll('.tools-remove-source') : [];
- (buttons[Math.min(index, buttons.length - 1)] || addButton).focus();
- }
- });
- browserList.lastElementChild.appendChild(remove);
+ addRemove([mode === 'pack' ? entry.originalPath : entry.path], 'Убрать ' + entry.path, 'Убрать файл');
}
}
if (!folders.size && !entries.length) {
diff --git a/tests/rpa_browser.cjs b/tests/rpa_browser.cjs
index 92f6c0a..d842f26 100644
--- a/tests/rpa_browser.cjs
+++ b/tests/rpa_browser.cjs
@@ -128,9 +128,27 @@ for (const [name, data] of Object.entries(resources)) {
].join('\n'), zipPath, fixtures]);
assert.deepEqual(errors, []);
- // Reach a file beyond the former 200-row limit, then remove it by keyboard.
+ // Remove a folder by keyboard, including its descendants, then the remaining root.
await page.locator('#tools-mode-pack').check();
await page.locator('#tools-clear').click();
+ await page.locator('#tools-folder').setInputFiles(fixtures);
+ await page.locator('#tools-browser-search').fill('');
+ await page.locator('#tools-browser-list button').filter({hasText: 'my_mod/'}).click();
+ const removeImages = page.getByRole('button', { name: 'Убрать папку my_mod/images/ и все вложенные файлы', exact: true });
+ await removeImages.focus();
+ await page.keyboard.press('Enter');
+ assert.match(await page.locator('#tools-count').textContent(), /3 файла/);
+ assert.equal(await page.locator('#tools-browser-list button').filter({hasText: 'images/'}).count(), 0);
+ assert.equal(await page.locator('#tools-browser-list button').filter({hasText: 'audio/'}).count(), 1);
+ assert(await page.locator('#tools-browser-list .tools-remove-source').nth(1).evaluate(el => el === document.activeElement));
+ await page.locator('#tools-browser-crumbs button').filter({hasText: 'Все файлы'}).click();
+ await page.getByRole('button', { name: 'Убрать папку my_mod/ и все вложенные файлы', exact: true }).focus();
+ await page.keyboard.press('Enter');
+ assert(!await page.locator('#tools-browser').isVisible());
+ assert(await page.locator('#tools-add').evaluate(el => el === document.activeElement));
+ assert(await page.locator('#tools-download-all').isDisabled());
+
+ // Reach a file beyond the former 200-row limit, then remove it by keyboard.
await page.locator('#tools-files').setInputFiles(Array.from({ length: 251 }, (_, i) => ({
name: `item-${String(i).padStart(3, '0')}.bin`, mimeType: 'application/octet-stream', buffer: Buffer.from([i % 256]),
})));
diff --git a/tests/tools_ui.test.mjs b/tests/tools_ui.test.mjs
index 65cfad6..f8e68d0 100644
--- a/tests/tools_ui.test.mjs
+++ b/tests/tools_ui.test.mjs
@@ -176,6 +176,44 @@ test('removing the last packing file returns keyboard focus to adding files', as
assert.equal(ui.document.activeElement, ui.get('add'));
});
+test('removing a packing folder drops all descendants using their original paths', async () => {
+ const ui = page('?mode=pack');
+ ui.get('pack-name').value = 'demo';
+ ui.get('pack-prefix').value = 'bundle';
+ ui.get('folder').files = ['images/bg.png', 'images/nested/icon.png', 'images2/keep.png'].map(path =>
+ ({ name: path.split('/').at(-1), webkitRelativePath: 'game/my_mod/' + path, size: 1 }));
+ ui.get('folder').emit('change');
+ await Promise.resolve();
+ ui.get('browser-list').children[0].children[0].click();
+ ui.get('browser-list').children[0].children[0].click();
+ const folder = ui.get('browser-list').children[0];
+ assert.equal(folder.children[0].children[1].textContent, 'images/');
+ const remove = folder.children[2];
+ assert.ok(remove, 'folder has a remove button');
+ assert.match(remove.attributes['aria-label'], /вложенные файлы/);
+ remove.focus(); remove.click();
+ assert.match(ui.get('count').textContent, /1 файл/);
+ assert.equal(ui.get('browser-list').children[0].children[0].children[1].textContent, 'images2/');
+ assert.equal(ui.document.activeElement, ui.get('browser-list').children[0].children[2]);
+ ui.get('download-all').click();
+ assert.equal(ui.messages.at(-1).files.length, 1);
+ assert.equal(ui.messages.at(-1).files[0].path, 'bundle/my_mod/images2/keep.png');
+});
+
+test('removing the root packing folder clears the queue and returns focus to adding', async () => {
+ const ui = page('?mode=pack');
+ ui.get('pack-name').value = 'demo';
+ ui.get('pack-prefix').value = 'my_mod';
+ await ui.add(['bg.png', 'theme.ogg']);
+ const remove = ui.get('browser-list').children[0].children[2];
+ assert.ok(remove, 'root folder has a remove button');
+ remove.focus(); remove.click();
+ assert.equal(ui.get('queue').hidden, true);
+ assert.equal(ui.get('browser').hidden, true);
+ assert.equal(ui.document.activeElement, ui.get('add'));
+ assert.equal(ui.get('download-all').disabled, true);
+});
+
test('packing previews keep compiled files intact and do not start decompilers', async () => {
const ui = page('?mode=pack');
ui.get('pack-name').value = 'demo';
From 8a5c3a383d4819a224374a9f238057c4c1b783bd Mon Sep 17 00:00:00 2001
From: somepoi <46904988+somepoi@users.noreply.github.com>
Date: Sat, 10 Oct 2026 12:35:00 +0300
Subject: [PATCH 06/27] feature: staged disclosure workflow added for tools
---
static/css/tools.css | 22 +++++
static/js/tools.js | 163 +++++++++++++++++++++++++++++-------
templates/tools_unpack.html | 51 ++++++-----
tests/rpa_browser.cjs | 94 ++++++++++++++++++---
tests/tools_ui.test.mjs | 141 +++++++++++++++++++++++++++++--
5 files changed, 407 insertions(+), 64 deletions(-)
diff --git a/static/css/tools.css b/static/css/tools.css
index 9c47cad..1bb5370 100644
--- a/static/css/tools.css
+++ b/static/css/tools.css
@@ -38,6 +38,25 @@
.tools-drop-icon { color: var(--text-soft); margin-bottom: 10px; }
.tools-drop-title { margin: 0 0 6px; font-size: var(--fs-body); font-weight: 600; }
.tools-drop p:not(.tools-drop-title) { color: var(--text-soft); font-size: var(--fs-ui); }
+.tools-drop.is-compact { display: flex; align-items: center; justify-content: space-between; gap: 12px 20px; padding: 12px 16px; text-align: start; }
+.tools-drop.is-compact .tools-drop-icon { display: none; }
+.tools-drop.is-compact .tools-drop-title { margin: 0; color: var(--text-soft); font-size: var(--fs-ui); font-weight: 400; }
+.tools-drop.is-compact .tools-button { flex: none; }
+.tools-export-dialog { width: min(42rem, calc(100% - 32px)); max-height: calc(100dvh - 32px); box-sizing: border-box; padding: 24px; border: 0; border-radius: var(--radius-block); background: var(--bg-elevated); color: var(--text); box-shadow: var(--shadow-modal); overflow-y: auto; scrollbar-color: var(--border) var(--bg-elevated); }
+.tools-export-dialog::backdrop { background: var(--scrim); }
+html:has(.tools-export-dialog[open]) { overflow: hidden; }
+.tools-dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
+.tools-dialog-heading h2 { margin: 0; font-family: var(--font-serif); font-size: var(--fs-subtitle); }
+.tools-dialog-close { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--radius-sm); background: none; color: var(--text-soft); cursor: pointer; }
+.tools-dialog-close:hover { color: var(--text); background: var(--bg-light); }
+.tools-export-dialog .tools-status { margin: 0 0 16px; }
+.tools-export-dialog .tools-status:empty { display: none; }
+.tools-export-dialog .tools-result-meta { margin-block: 16px; font-size: var(--fs-ui); }
+.tools-dialog-downloads { display: flex; align-items: flex-start; flex-direction: column; gap: 12px; margin-top: 20px; }
+.tools-dialog-downloads:not(:has(> :not([hidden]))) { display: none; }
+.tools-dialog-downloads .tools-meta { margin: 0; overflow-wrap: anywhere; }
+.tools-export-dialog .tools-warnings[open] > ul { position: static; width: auto; max-width: 100%; margin-top: 12px; }
+.tools-export-dialog .tools-pack-integration { padding-top: 24px; border-top: 1px solid var(--border); }
.tools-button { display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 10px 18px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; font-size: var(--fs-ui); font-weight: 600; cursor: pointer; text-decoration: none; }
.tools-button:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }
.tools-button-primary { background: var(--accent); border-color: var(--accent); color: var(--bg); }
@@ -199,6 +218,9 @@
.tools-page [hidden] { display: none !important; }
@media (max-width: 640px) {
.tools-drop { padding: 24px 14px 20px; }
+ .tools-drop.is-compact { flex-direction: column; align-items: stretch; padding: 12px 14px; text-align: center; }
+ .tools-export-dialog { padding: 20px; }
+ .tools-dialog-downloads > .tools-button, .tools-dialog-downloads .tools-downloads, .tools-dialog-downloads .tools-downloads > a { width: 100%; }
.tools-link-list a { grid-template-columns: 1fr auto; gap: 4px 12px; }
.tools-link-list a span:nth-child(2) { grid-column: 1; }
.tools-link-list a span:last-child { grid-column: 2; grid-row: 1 / 3; }
diff --git a/static/js/tools.js b/static/js/tools.js
index 04a7406..60a4e6e 100644
--- a/static/js/tools.js
+++ b/static/js/tools.js
@@ -13,6 +13,9 @@
const queue = get('queue'), list = get('file-list'), progress = get('progress');
const progressBar = get('progress-bar'), current = get('current');
const resultPanel = get('result'), downloads = get('downloads'), downloadAll = get('download-all');
+ const exportDialog = get('export-dialog'), exportStart = get('export-start');
+ const exportStatus = get('export-status'), exportErrors = get('export-errors');
+ const downloadButton = '
Скачать';
const warningsPanel = get('warnings'), warningList = get('warning-list');
const browseActions = get('browse-actions'), browseArchive = get('archive');
const browser = get('browser'), browserList = get('browser-list');
@@ -39,6 +42,7 @@
let browseEntries = [], browseFolder = '', browseRequest = 0, previewUrl = null;
let catalogEntries = [], catalogFiles = [], catalogRequest = 0, selectedEntry = null;
let task = '', exportReady = false, catalogSubmitted = false, previewFont = null, clearOnDownload = false;
+ let packDownloadStarted = false, exportFinished = false;
let operation = 0;
let previewPending = null, pendingPreviewKey = '';
const previewCache = new Map();
@@ -59,13 +63,28 @@
previewCacheBytes += bytes;
};
- const say = (message, error = false) => {
- status.textContent = message;
- status.classList.toggle('is-error', error);
- errorsPanel.hidden = true;
- errorsPanel.replaceChildren();
+ const say = (message, error = false, inExport = task === 'export') => {
+ const target = inExport ? exportStatus : status;
+ const diagnostics = inExport ? exportErrors : errorsPanel;
+ target.textContent = message;
+ target.classList.toggle('is-error', error);
+ diagnostics.hidden = true;
+ diagnostics.replaceChildren();
+ };
+ const notifyDownload = () => {
+ exportStatus.textContent = 'Скачивание запущено.';
+ exportStatus.classList.remove('is-error');
};
const clearResult = () => {
+ exportFinished = false;
+ if (exportDialog.open) exportDialog.close();
+ task = '';
+ exportStatus.textContent = '';
+ exportErrors.hidden = true;
+ exportErrors.replaceChildren();
+ get('export-summary').hidden = true;
+ packDownloadStarted = false;
+ get('pack-integration').hidden = true;
previewCache.clear();
previewCacheBytes = 0;
pendingPreviewKey = '';
@@ -91,6 +110,8 @@
outputs.length = 0;
chunks = [];
downloads.replaceChildren();
+ downloads.hidden = true;
+ get('export-info').hidden = true;
downloadAll.hidden = true;
resultPanel.hidden = true;
get('result-description').textContent = '';
@@ -118,6 +139,19 @@
const isArchive = entry => /\.rpa$/i.test(entry.path);
const updateControls = () => {
const locked = busy || enumerating || !supported;
+ const hasFiles = files.size > 0;
+ exportStart.hidden = !hasFiles && !exportFinished;
+ exportStart.disabled = locked || (!exportFinished && mode === 'pack' && !packValid);
+ exportStart.innerHTML = exportFinished ? '
Открыть результат' : downloadButton;
+ get('export-close').setAttribute('aria-label', busy && task === 'export' ? 'Отменить обработку и закрыть окно' : 'Закрыть окно');
+ get('pack-settings').hidden = mode !== 'pack' || !hasFiles;
+ get('options').hidden = mode === 'pack' || !hasFiles;
+ get('pack-integration').hidden = mode !== 'pack' || !packDownloadStarted;
+ drop.classList.toggle('is-compact', hasFiles);
+ get('drop-title').textContent = hasFiles ? 'Перетащите сюда ещё файлы или папку' : 'Перетащите файлы или папку сюда';
+ get('drop-help').hidden = hasFiles;
+ drop.setAttribute('aria-describedby', hasFiles ? 'tools-drop-title' : 'tools-drop-help');
+ get('add-label').textContent = hasFiles ? 'Добавить ещё' : 'Добавить файлы или папку';
start.disabled = locked || !selected().length || (mode === 'pack' && !packValid);
start.hidden = mode === 'pack' || !retryAvailable || locked || !selected().length;
addButton.disabled = locked;
@@ -129,7 +163,8 @@
queuePrevious.disabled = locked || queuePage === 0;
queueNext.disabled = locked || (queuePage + 1) * queuePageSize >= queueMatches;
cancel.hidden = !busy && !enumerating;
- const cancelHost = busy && task === 'preview' && previewPending ? previewPending : operationActions;
+ const cancelHost = busy && task === 'export' ? get('export-actions')
+ : busy && task === 'preview' && previewPending ? previewPending : operationActions;
if (cancel.parentNode !== cancelHost) cancelHost.appendChild(cancel);
downloadAll.disabled = locked || (!selected().length && !outputs.length) || (mode === 'pack' && !packValid);
browseArchive.disabled = locked;
@@ -311,9 +346,12 @@
const showOutputs = () => {
resultPanel.hidden = false;
start.classList.remove('tools-button-primary');
- downloadAll.hidden = false;
- downloadAll.innerHTML = '
'
- + (exportReady ? 'Скачать ещё раз' : mode === 'pack' ? 'Создать и скачать RPA' : 'Скачать всё в ZIP') + '';
+ const singleResult = exportReady && outputs.length === 1;
+ downloadAll.hidden = exportReady && outputs.length > 1;
+ downloadAll.innerHTML = exportFinished && !exportReady ? '
Повторить обработку' : downloadButton;
+ downloads.hidden = !outputs.length || singleResult;
+ get('export-info').hidden = !singleResult;
+ if (singleResult) get('export-info').textContent = `${outputs[0].name} / ${size(outputs[0].blob.size)}`;
if (outputs.length === 1 && mode !== 'pack') outputs[0].link.classList.add('tools-button-primary');
};
const options = () => ({ try_harder: get('try-harder').checked, no_init_offset: get('no-init-offset').checked });
@@ -393,11 +431,8 @@
get('pack-clear-label').hidden = mode !== 'pack';
get('pack-notice').hidden = mode !== 'pack';
get('queue-tools').hidden = mode !== 'pack';
- get('pack-integration').hidden = mode !== 'pack';
get('mode-pack').checked = mode === 'pack';
get('mode-unpack').checked = mode !== 'pack';
- get('pack-settings').hidden = mode !== 'pack';
- get('options').hidden = mode === 'pack';
get('pack-help').hidden = mode !== 'pack';
get('unpack-help').hidden = mode === 'pack';
get('queue-heading').textContent = mode === 'pack' ? 'Файлы в архиве' : 'Добавленные файлы';
@@ -803,7 +838,7 @@
empty.textContent = 'Файл не выбран';
browserPreview.appendChild(empty);
browser.hidden = false;
- get('browser-title').textContent = mode === 'pack' ? 'Пути внутри архива' : scripts ? 'Сценарии' : catalogFiles[source]?.path || 'Файлы';
+ get('browser-title').textContent = mode === 'pack' ? 'Пути внутри архива' : scripts ? 'Скрипты' : catalogFiles[source]?.path || 'Файлы';
renderBrowse();
};
on(browseArchive, 'change', openBrowse);
@@ -835,16 +870,17 @@
block.append(title, explanation, details);
return block;
};
- const showErrors = messages => {
+ const showErrors = (messages, inExport = task === 'export') => {
const errors = messages.slice(0, 100).map(message => explainError(message));
+ const diagnostics = inExport ? exportErrors : errorsPanel;
say(messages.length === 1 ? errors[0].title + (errors[0].path ? `: ${errors[0].path}` : '') + '.'
- : `Не удалось обработать файлов: ${messages.length.toLocaleString('ru-RU')}. Причины указаны ниже.`, true);
- errorsPanel.hidden = false;
- for (const error of errors) errorsPanel.appendChild(errorDiagnostic(error));
+ : `Не удалось обработать файлов: ${messages.length.toLocaleString('ru-RU')}. Причины указаны ниже.`, true, inExport);
+ diagnostics.hidden = false;
+ for (const error of errors) diagnostics.appendChild(errorDiagnostic(error));
if (messages.length > 100) {
const remainder = document.createElement('p');
remainder.textContent = `Показаны причины первых 100 ошибок из ${messages.length.toLocaleString('ru-RU')}. Обрабатывайте файлы меньшими наборами, чтобы увидеть остальные.`;
- errorsPanel.appendChild(remainder);
+ diagnostics.appendChild(remainder);
}
};
const endPreview = message => {
@@ -872,20 +908,26 @@
};
const fail = message => {
const interrupted = task;
+ if (interrupted === 'export') {
+ exportFinished = true;
+ get('export-title').textContent = 'Не удалось обработать файлы';
+ }
task = '';
catalogRequest++;
browseRequest++;
chunks = [];
resetWorker();
- retryAvailable = true;
+ retryAvailable = interrupted !== 'export';
finish();
- if (outputs.length) {
+ if (outputs.length && interrupted === 'export') {
showOutputs();
+ get('export-summary').hidden = false;
get('result-description').textContent = `Готовых архивов: ${outputs.length}.`;
}
if (interrupted === 'preview' || interrupted === 'catalog') endPreview('Просмотр прерван. Повторите действие или выберите другой файл.');
browserStatus.textContent = '';
- showErrors([message]);
+ showErrors([message], interrupted === 'export');
+ if (interrupted === 'export') showOutputs();
};
on(cancel, 'click', () => {
const interrupted = task;
@@ -896,7 +938,18 @@
catalogRequest++;
browseRequest++;
chunks = [];
- retryAvailable = true;
+ retryAvailable = interrupted !== 'export';
+ if (interrupted === 'export') {
+ exportFinished = outputs.length > 0;
+ exportReady = false;
+ get('export-title').textContent = 'Обработка отменена';
+ if (exportFinished) {
+ get('export-summary').hidden = false;
+ get('result-description').textContent = `Готовых архивов: ${outputs.length}.`;
+ showOutputs();
+ }
+ if (exportDialog.open) exportDialog.close();
+ }
finish();
render();
if (interrupted === 'preview' || interrupted === 'catalog') endPreview('Просмотр отменён. Выберите файл, чтобы открыть его ещё раз.');
@@ -971,11 +1024,22 @@
link.className = 'tools-button';
link.href = url;
link.download = data.name;
- link.textContent = `${data.name} / ${size(blob.size)}`;
+ link.innerHTML = '
';
+ const label = document.createElement('span');
+ label.textContent = 'Скачать ' + data.name;
+ link.appendChild(label);
+ link.title = `${data.name} / ${size(blob.size)}`;
// Downloads do not expose a completion event to the page.
// Clear source selections when the user starts a download;
// keep result URLs available for retries and remaining ZIPs.
on(link, 'click', () => {
+ if (mode === 'pack' && exportReady) {
+ packDownloadStarted = true;
+ get('pack-install-archive').textContent = 'game/my_mod/' + data.name;
+ get('pack-install-string').textContent = JSON.stringify('my_mod/' + data.name.slice(0, -4));
+ updateControls();
+ }
+ notifyDownload();
if (!clearOnDownload) return;
for (const entry of activeFiles) files.delete(entry.path);
activeFiles = [];
@@ -990,10 +1054,14 @@
} else if (data.type === 'fatal') fail(data.error);
else if (data.type === 'done') {
const result = data.result;
+ exportFinished = true;
+ get('export-summary').hidden = false;
+ get('export-title').textContent = result.failed || !outputs.length
+ ? outputs.length ? 'Готово с ошибками' : 'Не удалось обработать файлы' : 'Готово';
chunks = [];
retryAvailable = false;
clearOnDownload = result.failed === 0 && mode !== 'pack';
- exportReady = result.failed === 0;
+ exportReady = result.failed === 0 && outputs.length > 0;
finish();
if (mode === 'pack') render();
resultPanel.hidden = false;
@@ -1005,18 +1073,43 @@
renderWarnings(warningsPanel, get('warnings-summary'), warningList,
result.warnings, result.warning_details || []);
if (result.failed) showErrors(result.errors.length ? result.errors : ['Нет готовых файлов.']);
- else say('Готово.');
+ else say('');
for (const output of outputs) output.link.click();
} else {
get('result-description').textContent = 'Файлы не удалось обработать.';
showErrors(result.errors.length ? result.errors : ['Нет готовых файлов.']);
+ showOutputs();
}
+ task = '';
}
};
on(downloadAll, 'click', () => {
if (exportReady) for (const output of outputs) output.link.click();
else processFiles();
});
+ const openExportDialog = () => {
+ closeMenu();
+ if (!exportDialog.open) exportDialog.showModal();
+ get('export-close').focus();
+ };
+ const closeExportDialog = () => {
+ if (busy && task === 'export') cancel.click();
+ if (exportDialog.open) exportDialog.close();
+ };
+ on(exportStart, 'click', () => {
+ if (busy || enumerating) return;
+ if (exportFinished) openExportDialog();
+ else processFiles();
+ });
+ on(get('export-close'), 'click', closeExportDialog);
+ on(exportDialog, 'cancel', event => { event.preventDefault(); closeExportDialog(); });
+ on(exportDialog, 'click', event => {
+ if (event.target !== exportDialog) return;
+ const bounds = exportDialog.getBoundingClientRect();
+ if (event.clientX < bounds.left || event.clientX > bounds.right
+ || event.clientY < bounds.top || event.clientY > bounds.bottom) closeExportDialog();
+ });
+ on(exportDialog, 'close', () => { if (!disposed) (exportStart.hidden ? addButton : exportStart).focus(); });
const ensureWorker = () => {
if (worker) return;
worker = new Worker(config.worker, { type: 'module', name: 'esdoc-renpy-tools' });
@@ -1055,7 +1148,7 @@
if (scripts.length) {
const option = document.createElement('option');
option.value = 'scripts';
- option.textContent = 'Сценарии';
+ option.textContent = 'Скрипты';
browseArchive.appendChild(option);
}
for (const { source, entry } of archives) {
@@ -1069,7 +1162,7 @@
get('archive-label').hidden = browseActions.hidden;
resultPanel.hidden = false;
downloadAll.hidden = false;
- downloadAll.innerHTML = '
Скачать всё в ZIP';
+ downloadAll.innerHTML = downloadButton;
openBrowse();
if (!archives.length) {
updateControls();
@@ -1091,9 +1184,18 @@
for (const output of outputs) URL.revokeObjectURL(output.url);
outputs.length = 0;
downloads.replaceChildren();
+ downloads.hidden = true;
+ get('export-info').hidden = true;
warningsPanel.hidden = true;
+ downloadAll.hidden = true;
chunks = [];
exportReady = false;
+ exportFinished = false;
+ get('export-summary').hidden = true;
+ get('export-title').textContent = mode === 'pack' ? 'Упаковка в RPA' : 'Обработка файлов';
+ status.textContent = '';
+ errorsPanel.hidden = true;
+ packDownloadStarted = false;
clearOnDownload = false;
retryAvailable = false;
busy = true;
@@ -1104,6 +1206,7 @@
progressBar.removeAttribute('value');
current.textContent = '';
say('Подготовка к обработке…');
+ openExportDialog();
try {
ensureWorker();
if (mode === 'pack') worker.postMessage({ type: 'pack', config, name: archiveName(packName.value),
@@ -1116,10 +1219,14 @@
for (const output of outputs) URL.revokeObjectURL(output.url);
outputs.length = 0;
downloads.replaceChildren();
+ downloads.hidden = true;
+ get('export-info').hidden = true;
exportReady = false;
+ exportFinished = false;
+ if (exportDialog.open) exportDialog.close();
downloadAll.hidden = !selected().length;
updateControls();
- if (selected().length) downloadAll.innerHTML = '
Скачать всё в ZIP';
+ if (selected().length) downloadAll.innerHTML = downloadButton;
if (option === get('try-harder')) {
catalogSubmitted = false;
if (selected().some(isArchive)) {
diff --git a/templates/tools_unpack.html b/templates/tools_unpack.html
index 2d3f23c..5327bf9 100644
--- a/templates/tools_unpack.html
+++ b/templates/tools_unpack.html
@@ -31,9 +31,9 @@
-
'
+def banner_end(state: StateBlock, startLine: int, endLine: int):
+ """Optional bounded note body and the line where parsing resumes."""
+ nextLine = startLine + 1
+ while nextLine < endLine:
+ pos = state.bMarks[nextLine] + state.tShift[nextLine]
+ line = state.src[pos:state.eMarks[nextLine]].strip()
+ if line == ':::':
+ return nextLine, nextLine + 1
+ if not line or line.startswith(':::'):
+ break
+ nextLine += 1
+ return startLine + 1, startLine + 1
+
+
def banner():
# `:::stub`, or `:::stub <короткая приписка>` on the same line. A multi-line
# note may follow, closed by `:::` — but only while the lines run on without
@@ -62,26 +76,7 @@ def block(state: StateBlock, startLine: int, endLine: int, silent: bool):
# Find the optional note body. bodyEnd stays at startLine + 1 for the
# bare single-line form; nextLine is where parsing resumes afterwards.
- bodyEnd = nextLine = startLine + 1
-
- while nextLine < endLine:
- pos = state.bMarks[nextLine] + state.tShift[nextLine]
- line = state.src[pos:state.eMarks[nextLine]].strip()
-
- if line == ':::':
- bodyEnd = nextLine
- nextLine += 1
- break
-
- # Blank line, or the start of some other block — the banner was
- # written in its bare form and this belongs to the document.
- if not line or line.startswith(':::'):
- bodyEnd = nextLine = startLine + 1
- break
-
- nextLine += 1
- else:
- bodyEnd = nextLine = startLine + 1
+ bodyEnd, nextLine = banner_end(state, startLine, endLine)
token = state.push('banner_open', 'aside', 1)
token.meta = {'kind': name}
diff --git a/app/utils/md/containers.py b/app/utils/md/containers.py
index 009242c..34a47d0 100644
--- a/app/utils/md/containers.py
+++ b/app/utils/md/containers.py
@@ -22,8 +22,7 @@
Banners are a deliberate exception on the *inside* — they keep their own
bounded scan (banner.py), because a banner is one or two sentences of page
status and should never grow into a container. They are still counted here,
-because from the outside a banner opens and closes with the same markers as
-everything else.
+with the same bounded rule here, so a standalone banner consumes no closer.
"""
import re
@@ -103,8 +102,13 @@ def find_closer(state, startLine: int, endLine: int) -> int | None:
if depth == 0:
return line
depth -= 1
- elif opener_name(text):
- depth += 1
+ elif name := opener_name(text):
+ if name in _NAMES:
+ depth += 1
+ else:
+ from .banner import banner_end
+ _, next_line = banner_end(state, line, endLine)
+ line = next_line - 1
line += 1
diff --git a/tests/test_md_containers.py b/tests/test_md_containers.py
index e703f70..2a9bd6d 100644
--- a/tests/test_md_containers.py
+++ b/tests/test_md_containers.py
@@ -1,11 +1,31 @@
import re
import unittest
from html import unescape
+from unittest.mock import patch
+from app.utils.config import CONFIG
from app.utils.md import render
class ContainerFenceTests(unittest.TestCase):
+ def test_standalone_banner_does_not_steal_the_outer_closer(self):
+ with patch.object(CONFIG, 'config', {}):
+ for marker in (':::stub', ':::stub A short note'):
+ with self.subTest(marker=marker):
+ html = render(f':::details Example\n{marker}\n\nINSIDE\n:::\n\nOUTSIDE\n')[2]
+ self.assertIn('
'))
+ self.assertGreater(html.index('OUTSIDE'), html.index(' '))
+
+ def test_paired_banner_keeps_its_own_closer(self):
+ with patch.object(CONFIG, 'config', {}):
+ html = render(':::details Example\n:::stub\nA note\n:::\n\nINSIDE\n:::\n\nOUTSIDE\n')[2]
+ self.assertIn('
'))
+ self.assertLess(html.index(''), html.index('INSIDE'))
+ self.assertLess(html.index('INSIDE'), html.index(' '))
+
def test_fenced_marker_examples_do_not_close_or_open_containers(self):
for fence in ('```', '~~~~'):
for marker in (':::', ':::info', ':::details Literal'):
From 51a5eb41b704ea68e82180e5f8af54a9c51c9818 Mon Sep 17 00:00:00 2001
From: somepoi <46904988+somepoi@users.noreply.github.com>
Date: Sat, 10 Oct 2026 18:15:08 +0300
Subject: [PATCH 17/27] fix: keep article and code rendering off the event loop
---
app/routes/docs.py | 3 +-
app/routes/resources.py | 3 +-
tests/test_render_concurrency.py | 49 ++++++++++++++++++++++++++++++++
3 files changed, 53 insertions(+), 2 deletions(-)
create mode 100644 tests/test_render_concurrency.py
diff --git a/app/routes/docs.py b/app/routes/docs.py
index 0d2fb7b..f1ed9d1 100644
--- a/app/routes/docs.py
+++ b/app/routes/docs.py
@@ -1,3 +1,4 @@
+import asyncio
import re
from urllib.parse import quote
@@ -89,7 +90,7 @@ async def page(doc, request: Request):
if any(i in request.query_params for i in ['s', 'search']):
return PlainTextResponse(re.sub(r'^(#+ )|(- )|(> )|(```\w*)|(:::\w*)|(---)$', '', text, flags=re.MULTILINE).replace('\n\n', '\n'))
- title, nav, body = render(text)
+ title, nav, body = await asyncio.to_thread(render, text)
if any(i in request.query_params for i in ['r', 'raw']):
return PlainTextResponse(body)
diff --git a/app/routes/resources.py b/app/routes/resources.py
index 1a475c3..b37ad5a 100644
--- a/app/routes/resources.py
+++ b/app/routes/resources.py
@@ -1,3 +1,4 @@
+import asyncio
import html
import os
from pathlib import Path
@@ -218,7 +219,7 @@ async def browser(request: Request, path=''):
if target.is_file():
size = target.stat().st_size
kind = FILE_KINDS.get(target.suffix.lower(), 'other')
- view = _file_view(target)
+ view = await asyncio.to_thread(_file_view, target)
sub = [KIND_LABELS[kind], _human_size(size)]
if view['mode'] == 'code':
sub.append(view['lines'])
diff --git a/tests/test_render_concurrency.py b/tests/test_render_concurrency.py
new file mode 100644
index 0000000..eb1cabf
--- /dev/null
+++ b/tests/test_render_concurrency.py
@@ -0,0 +1,49 @@
+import asyncio
+import importlib
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from threading import Event
+import unittest
+from unittest.mock import patch
+
+from httpx import ASGITransport, AsyncClient
+
+from app.app import app
+from app.utils.config import CONFIG
+
+
+class RenderConcurrencyTests(unittest.IsolatedAsyncioTestCase):
+ async def assert_responsive(self, module_name, function_name, url, setting, filename, source):
+ module = importlib.import_module(module_name)
+ render = getattr(module, function_name)
+ started, release = Event(), Event()
+
+ def gated_render(*args):
+ started.set()
+ release.wait(2)
+ return render(*args)
+
+ with TemporaryDirectory() as directory:
+ Path(directory, filename).write_text(source, encoding='utf-8')
+ with (patch.object(CONFIG, setting, Path(directory)),
+ patch.object(module, function_name, gated_render)):
+ async with AsyncClient(transport=ASGITransport(app=app), base_url='http://localhost') as client:
+ page = asyncio.create_task(client.get(url))
+ try:
+ self.assertTrue(await asyncio.to_thread(started.wait, 3))
+ health = await client.get('/healthz')
+ self.assertEqual(health.status_code, 200)
+ self.assertFalse(page.done(), 'Rendering blocked the event loop until it finished')
+ finally:
+ release.set()
+ response = await page
+ self.assertEqual(response.status_code, 200)
+ self.assertIn('hello', response.text)
+
+ async def test_article_rendering_allows_other_requests(self):
+ await self.assert_responsive('app.routes.docs', 'render', '/docs/demo',
+ 'docs_path', 'demo.md', '# Demo\n\nhello')
+
+ async def test_code_preview_allows_other_requests(self):
+ await self.assert_responsive('app.routes.resources', '_file_view', '/resources/browser/demo.rpy',
+ 'res_path', 'demo.rpy', 'label hello:\n pass\n')
From 3ebfe090a1bedc2f274f1b216d4c15e72e00172c Mon Sep 17 00:00:00 2001
From: somepoi <46904988+somepoi@users.noreply.github.com>
Date: Sat, 10 Oct 2026 18:16:12 +0300
Subject: [PATCH 18/27] fix: bound archive preview reads before allocating
assets
---
static/tools/engine.py | 10 +++++++++-
tests/test_tools_engine.py | 36 ++++++++++++++++++++++++++++++++++++
2 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/static/tools/engine.py b/static/tools/engine.py
index f5a4fa3..39bf751 100644
--- a/static/tools/engine.py
+++ b/static/tools/engine.py
@@ -18,6 +18,7 @@
from unrpa import UnRPA
CHUNK = 256 * 1024
+MAX_PREVIEW_BYTES = 64 * 1024 * 1024
def safe_path(value):
@@ -304,15 +305,22 @@ def read(self, key, options):
warnings.append(f'Исходный файл {target} уже есть в архиве, сохранён без замены.')
path = target
compiled = False
+ size = entry['size'] if segments is None else sum(length for _, length, _ in segments)
+ if size > MAX_PREVIEW_BYTES:
+ raise ValueError('Файл больше 64 МБ. Скачайте архив для просмотра.')
with open(self.files[entry['source']]['source'], 'rb') as source:
if segments is None:
- data = source.read()
+ data = source.read(MAX_PREVIEW_BYTES + 1)
else:
data = b''.join(iter(Segments(source, segments).read, b''))
+ if len(data) > MAX_PREVIEW_BYTES:
+ raise ValueError('Файл больше 64 МБ. Скачайте архив для просмотра.')
if compiled:
data, logs = pyc_decompiler.decompile(data) if path.lower().endswith('.pyc') else decompile(data, options)
warnings.extend(str(log) for log in logs)
path = target
+ if len(data) > MAX_PREVIEW_BYTES:
+ raise ValueError('Файл больше 64 МБ. Скачайте архив для просмотра.')
result = (path, data, warnings)
# Cache the current preview only, not every expanded archive asset.
self.cached = (cache_key, result)
diff --git a/tests/test_tools_engine.py b/tests/test_tools_engine.py
index c72b2cd..3cfc792 100644
--- a/tests/test_tools_engine.py
+++ b/tests/test_tools_engine.py
@@ -133,6 +133,42 @@ def test_lazy_preview_preserves_existing_source_and_split_segments(self):
self.assertEqual((path, data), ('script.rpy', b'label start:\n pass\n'))
self.assertEqual(len(warnings), 1)
+ def test_preview_rejects_oversized_entries_before_reading_them(self):
+ with TemporaryDirectory(dir=ROOT / 'temp') as directory:
+ source = Path(directory) / 'archive'
+ source.write_bytes(archive({'big.bin': [(b'0123456789', b'prefix')],
+ 'small.txt': [(b'ok', b'')]}))
+ catalog = self.engine.Catalog([{'path': 'data.rpa', 'source': str(source)}])
+ self.assertEqual(len(catalog.listing()), 2)
+ with patch.object(self.engine, 'MAX_PREVIEW_BYTES', 8, create=True):
+ with patch('builtins.open', side_effect=AssertionError('Oversized entry was opened')):
+ with self.assertRaisesRegex(ValueError, 'Скачайте архив'):
+ catalog.read('0:big.bin', {})
+ self.assertIsNone(catalog.cached)
+ self.assertEqual(catalog.read('0:small.txt', {})[1], b'ok')
+
+ def test_preview_limit_uses_existing_source_size_instead_of_compiled_size(self):
+ with TemporaryDirectory(dir=ROOT / 'temp') as directory:
+ source = Path(directory) / 'archive'
+ source.write_bytes(archive({'large.rpyc': [(b'x', b'')],
+ 'large.rpy': [(b'0123456789', b'')],
+ 'small.rpyc': [(b'0123456789', b'')],
+ 'small.rpy': [(b'ok', b'')]}))
+ catalog = self.engine.Catalog([{'path': 'data.rpa', 'source': str(source)}])
+ with patch.object(self.engine, 'MAX_PREVIEW_BYTES', 8, create=True):
+ with self.assertRaisesRegex(ValueError, 'Скачайте архив'):
+ catalog.read('0:large.rpyc', {})
+ self.assertEqual(catalog.read('0:small.rpyc', {})[:2], ('small.rpy', b'ok'))
+
+ def test_preview_limit_also_applies_to_loose_compiled_files(self):
+ with TemporaryDirectory(dir=ROOT / 'temp') as directory:
+ source = Path(directory) / 'script'
+ source.write_bytes(b'0123456789')
+ catalog = self.engine.Catalog([{'path': 'script.rpyc', 'source': str(source)}])
+ with patch.object(self.engine, 'MAX_PREVIEW_BYTES', 8, create=True):
+ with self.assertRaisesRegex(ValueError, 'Скачайте архив'):
+ catalog.read('0:script.rpyc', {})
+
def test_catalog_reports_bad_archives_without_hiding_good_sources(self):
with TemporaryDirectory(dir=ROOT / 'temp') as directory:
source = Path(directory) / 'good'
From d24f02782f8b07a0e2af8e88440e10ce59210b37 Mon Sep 17 00:00:00 2001
From: somepoi <46904988+somepoi@users.noreply.github.com>
Date: Sat, 10 Oct 2026 18:16:48 +0300
Subject: [PATCH 19/27] fix: discard pending search results when queries change
or close
---
static/js/search.js | 9 ++++-
tests/search_results.test.mjs | 63 ++++++++++++++++++++++++++++++++---
2 files changed, 66 insertions(+), 6 deletions(-)
diff --git a/static/js/search.js b/static/js/search.js
index f1b939b..34ef52f 100644
--- a/static/js/search.js
+++ b/static/js/search.js
@@ -37,6 +37,8 @@
function open() { list.hidden = false; input.setAttribute('aria-expanded', 'true'); }
function close() {
+ ++seq;
+ clearTimeout(timer);
list.hidden = true;
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
@@ -153,7 +155,11 @@
// ── Events ───────────────────────────────────────────────
var timer;
input.addEventListener('input', function () {
- clearTimeout(timer);
+ close();
+ matches = [];
+ list.innerHTML = '';
+ announce('');
+ if (!input.value.trim()) return;
timer = setTimeout(update, 120);
});
@@ -192,6 +198,7 @@
input.focus();
}
function closeSheet(returnFocus) {
+ close();
var wasOpen = document.body.classList.contains('search-open');
document.body.classList.remove('search-open');
if (toggle) toggle.setAttribute('aria-expanded', 'false');
diff --git a/tests/search_results.test.mjs b/tests/search_results.test.mjs
index 0e50588..f825dbc 100644
--- a/tests/search_results.test.mjs
+++ b/tests/search_results.test.mjs
@@ -5,7 +5,7 @@ import vm from 'node:vm';
const script = readFileSync(new URL('../static/js/search.js', import.meta.url), 'utf8');
-async function searchBox(rows) {
+async function searchBox(rows, fetchRows = async () => rows) {
const handlers = {};
const attrs = {};
const input = {
@@ -28,18 +28,71 @@ async function searchBox(rows) {
vm.runInNewContext(script, {
document: {
getElementById: id => ({ 'site-search': form, 'site-search-input': input, 'site-search-results': list })[id],
- querySelector: () => null, addEventListener: () => {},
- body: { classList: { add: () => {} } },
+ querySelector: () => null,
+ addEventListener: (event, handler) => { handlers['document-' + event] = handler; },
+ body: { classList: { add: () => {}, remove: () => {}, contains: () => false } },
},
- window, fetch: async () => ({ ok: true, json: async () => rows }),
+ window, fetch: async () => ({ ok: true, json: fetchRows }),
setTimeout: fn => { update = fn; }, clearTimeout: () => {},
});
handlers.input();
update();
await new Promise(resolve => setImmediate(resolve));
- return { handlers, input, list, status, window, attrs };
+ return { handlers, input, list, status, window, attrs, flush: () => update() };
}
+for (const dismiss of ['clear', 'Escape', 'outside click']) {
+ for (const fails of [false, true]) {
+ test(`pending search ${fails ? 'failure' : 'results'} stays dismissed after ${dismiss}`, async () => {
+ let resolve, reject;
+ const pending = new Promise((yes, no) => { resolve = yes; reject = no; });
+ const box = await searchBox([], () => pending);
+ if (dismiss === 'clear') {
+ box.input.value = '';
+ box.handlers.input();
+ box.flush();
+ } else if (dismiss === 'Escape') {
+ box.handlers.keydown({ key: 'Escape', preventDefault: () => {} });
+ } else {
+ box.handlers['document-click']({ target: {} });
+ }
+ if (fails) reject(new Error('offline'));
+ else resolve([{ label: 'Луна', url: '/stale' }]);
+ await new Promise(done => setImmediate(done));
+ assert.equal(box.list.hidden, true);
+ assert.equal(box.attrs['aria-expanded'], 'false');
+ assert.equal(box.status.textContent || '', '');
+ });
+ }
+}
+
+test('typing another query invalidates results before its debounce fires', async () => {
+ let resolve;
+ const pending = new Promise(done => { resolve = done; });
+ const box = await searchBox([], () => pending);
+ box.input.value = 'Ночь';
+ box.handlers.input();
+ resolve([{ label: 'Луна', url: '/stale' }]);
+ await new Promise(done => setImmediate(done));
+ assert.equal(box.list.hidden, true);
+ assert.ok(!box.list.innerHTML.includes('Луна'));
+});
+
+test('an older response cannot replace results from the latest query', async () => {
+ let resolveOld;
+ const old = new Promise(done => { resolveOld = done; });
+ let calls = 0;
+ const box = await searchBox([], () => ++calls === 1 ? old : [{ label: 'Ночь', url: '/latest' }]);
+ box.input.value = 'Ночь';
+ box.handlers.input();
+ box.flush();
+ await new Promise(done => setImmediate(done));
+ resolveOld([{ label: 'Луна', url: '/stale' }]);
+ await new Promise(done => setImmediate(done));
+ box.handlers.keydown({ key: 'Enter', preventDefault: () => {} });
+ assert.equal(box.window.location.href, '/latest');
+});
+
test('mixed global results have distinct groups and retain ranking within each group', async () => {
const box = await searchBox([
{ kind: 'file', label: 'Луна.rpy', url: '/resources/browser/Луна.rpy' },
From 3f79203ff1997fea02501cffa07aee77f60bacf9 Mon Sep 17 00:00:00 2001
From: somepoi <46904988+somepoi@users.noreply.github.com>
Date: Sat, 10 Oct 2026 18:19:14 +0300
Subject: [PATCH 20/27] fix: honor the latest navigation during overlapping
page loads
---
static/js/navigation.js | 98 ++++++++++++--------
tests/navigation.test.mjs | 190 ++++++++++++++++++++++++++++++++++++++
2 files changed, 247 insertions(+), 41 deletions(-)
create mode 100644 tests/navigation.test.mjs
diff --git a/static/js/navigation.js b/static/js/navigation.js
index eb007e0..5163264 100644
--- a/static/js/navigation.js
+++ b/static/js/navigation.js
@@ -12,7 +12,8 @@
if (!content || !window.fetch || !window.DOMParser) return;
let navigationId = 0;
- let navigating = false;
+ let navigationController;
+ let pageQueue = Promise.resolve();
const sectionFor = pathname => {
if (pathname === '/') return '';
@@ -37,7 +38,8 @@
});
};
- const updateStyles = async nextDocument => {
+ const updateStyles = async (nextDocument, signal) => {
+ if (signal.aborted) return;
const hrefs = [...nextDocument.head.querySelectorAll('link[data-page-style]')]
.map(link => link.href)
.filter(Boolean);
@@ -51,21 +53,29 @@
});
await Promise.all(newLinks.map(link => new Promise(resolve => {
- link.addEventListener('load', resolve, { once: true });
- link.addEventListener('error', resolve, { once: true });
+ const finish = () => {
+ signal.removeEventListener('abort', cancel);
+ resolve();
+ };
+ const cancel = () => { link.remove(); finish(); };
+ signal.addEventListener('abort', cancel, { once: true });
+ link.addEventListener('load', finish, { once: true });
+ link.addEventListener('error', finish, { once: true });
document.head.appendChild(link);
})));
- oldLinks.forEach(link => link.remove());
+ if (signal.aborted) newLinks.forEach(link => link.remove());
+ else oldLinks.forEach(link => link.remove());
};
- const loadPageScripts = async nextDocument => {
+ const loadPageScripts = async (nextDocument, id) => {
document.querySelectorAll('script[data-soft-page-script]').forEach(script => script.remove());
const sources = [...nextDocument.head.querySelectorAll('script[data-page-script][src]')]
.map(script => script.src);
for (const src of sources) {
+ if (id !== navigationId) return;
await new Promise(resolve => {
const script = document.createElement('script');
script.src = src;
@@ -78,34 +88,9 @@
}
};
- const loadPage = async (href, pushHistory) => {
- const id = ++navigationId;
- const url = new URL(href, location.href);
-
- let response;
- try {
- response = await fetch(url.href, {
- credentials: 'same-origin',
- headers: { Accept: 'text/html' },
- });
- } catch (error) {
- location.href = url.href;
- return;
- }
-
- if (!response.ok || !response.headers.get('content-type')?.includes('text/html')) {
- location.href = url.href;
- return;
- }
-
- const nextDocument = new DOMParser().parseFromString(await response.text(), 'text/html');
- const nextContent = nextDocument.getElementById('site-content');
- if (!nextContent || id !== navigationId) {
- if (id === navigationId) location.href = url.href;
- return;
- }
-
- await updateStyles(nextDocument);
+ const commitPage = async (nextDocument, nextContent, url, pushHistory, id, signal) => {
+ if (id !== navigationId) return;
+ await updateStyles(nextDocument, signal);
if (id !== navigationId) return;
window.__esdocWarperCleanup?.();
@@ -131,7 +116,7 @@
if (pushHistory) history.pushState({}, '', url.href);
if (!url.hash) window.scrollTo(0, 0);
- await loadPageScripts(nextDocument);
+ await loadPageScripts(nextDocument, id);
if (id !== navigationId) return;
// Soft navigation replaces the document without the browser's native
@@ -148,6 +133,40 @@
window.dispatchEvent(new CustomEvent('esdoc:navigation', { detail: { url: url.href } }));
};
+ const loadPage = async (href, pushHistory) => {
+ const id = ++navigationId;
+ navigationController?.abort();
+ navigationController = new AbortController();
+ const signal = navigationController.signal;
+ const url = new URL(href, location.href);
+
+ try {
+ const response = await fetch(url.href, {
+ credentials: 'same-origin',
+ headers: { Accept: 'text/html' },
+ signal,
+ });
+ if (id !== navigationId) return;
+ if (!response.ok || !response.headers.get('content-type')?.includes('text/html')) {
+ location.href = url.href;
+ return;
+ }
+ const text = await response.text();
+ if (id !== navigationId) return;
+ const nextDocument = new DOMParser().parseFromString(text, 'text/html');
+ const nextContent = nextDocument.getElementById('site-content');
+ if (!nextContent) { location.href = url.href; return; }
+
+ // A pending classic script must finish against its original DOM.
+ // Fetches can overlap, but page replacement and scripts run in order.
+ const job = pageQueue.then(() => commitPage(nextDocument, nextContent, url, pushHistory, id, signal));
+ pageQueue = job.catch(() => {});
+ await job;
+ } catch (error) {
+ if (id === navigationId) location.href = url.href;
+ }
+ };
+
const shouldHandle = (event, link) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey
|| event.shiftKey || event.altKey || link.target || link.hasAttribute('download')) return false;
@@ -160,16 +179,13 @@
document.addEventListener('click', event => {
const link = event.target.closest?.('a');
- if (!link || !shouldHandle(event, link) || navigating) return;
+ if (!link || !shouldHandle(event, link)) return;
event.preventDefault();
- navigating = true;
- loadPage(link.href, true).finally(() => { navigating = false; });
+ loadPage(link.href, true);
});
window.addEventListener('popstate', () => {
- if (navigating) return;
- navigating = true;
- loadPage(location.href, false).finally(() => { navigating = false; });
+ loadPage(location.href, false);
});
})();
diff --git a/tests/navigation.test.mjs b/tests/navigation.test.mjs
new file mode 100644
index 0000000..98236e4
--- /dev/null
+++ b/tests/navigation.test.mjs
@@ -0,0 +1,190 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import vm from 'node:vm';
+
+const source = readFileSync(new URL('../static/js/navigation.js', import.meta.url), 'utf8');
+const tick = () => new Promise(resolve => setImmediate(resolve));
+
+function navigation({ holdStyles = false, holdScripts = false } = {}) {
+ const events = {}, requests = [], links = [], scripts = [], pushes = [], redirects = [];
+ const content = { innerHTML: 'initial' };
+ let current = new URL('https://example.test/docs/current');
+ const location = {
+ get href() { return current.href; },
+ set href(value) { redirects.push(value); current = new URL(value, current); },
+ get origin() { return current.origin; },
+ get pathname() { return current.pathname; },
+ get search() { return current.search; },
+ };
+ function element(kind) {
+ const handlers = {};
+ return {
+ kind, dataset: {}, removed: false,
+ addEventListener: (name, fn) => { handlers[name] = fn; },
+ remove() { this.removed = true; },
+ loaded() { handlers.load?.(); },
+ };
+ }
+ const document = {
+ title: 'initial', body: { className: '' }, documentElement: { dataset: {} },
+ getElementById: () => content,
+ addEventListener: (name, fn) => { events['document-' + name] = fn; },
+ querySelectorAll: selector => selector.startsWith('script') ? scripts.filter(s => !s.removed) : [],
+ createElement: element,
+ head: {
+ querySelectorAll: () => links.filter(link => !link.removed),
+ appendChild(item) {
+ (item.kind === 'link' ? links : scripts).push(item);
+ if (item.kind === 'link' ? !holdStyles : !holdScripts) queueMicrotask(() => item.loaded());
+ },
+ },
+ };
+ const fetch = (url, options) => new Promise((resolve, reject) => requests.push({ url, options, resolve, reject }));
+ const window = {
+ fetch, DOMParser: true, scrollTo() {}, dispatchEvent() {},
+ addEventListener: (name, fn) => { events[name] = fn; },
+ };
+ const history = { pushState(state, title, url) { pushes.push(url); current = new URL(url); } };
+ class DOMParser {
+ parseFromString(text) {
+ const page = JSON.parse(text);
+ return {
+ title: page.name, body: { className: page.name },
+ getElementById: () => ({ innerHTML: page.name }),
+ head: { querySelectorAll: selector => selector.startsWith('link')
+ ? (page.styles || []).map(href => ({ href }))
+ : (page.scripts || []).map(src => ({ src })) },
+ };
+ }
+ }
+ vm.runInNewContext(source, {
+ window, document, fetch, location, history, DOMParser, URL, AbortController,
+ CustomEvent: class {}, localStorage: { getItem: () => null },
+ });
+ return {
+ content, document, location, requests, links, scripts, pushes, redirects,
+ pop(path) { current = new URL(path, current); events.popstate(); },
+ click(path) {
+ let prevented = false;
+ events['document-click']({
+ button: 0, target: { closest: () => ({ href: new URL(path, current).href, hasAttribute: () => false }) },
+ preventDefault() { prevented = true; },
+ });
+ return prevented;
+ },
+ respond(index, page, { ok = true, type = 'text/html' } = {}) {
+ requests[index].resolve({ ok, headers: { get: () => type }, text: async () => JSON.stringify(page) });
+ },
+ };
+}
+
+test('the latest Back or Forward event wins even while the previous fetch is pending', async () => {
+ const nav = navigation();
+ nav.pop('/docs/first');
+ nav.pop('/docs/latest');
+ assert.equal(nav.requests.length, 2);
+ assert.equal(nav.requests[0].options.signal.aborted, true);
+ nav.respond(1, { name: 'latest' });
+ await tick();
+ nav.respond(0, { name: 'first' });
+ await tick();
+ assert.equal(nav.content.innerHTML, 'latest');
+ assert.equal(nav.location.pathname, '/docs/latest');
+ assert.deepEqual(nav.pushes, []);
+});
+
+test('Back supersedes a pending clicked link without creating another history entry', async () => {
+ const nav = navigation();
+ assert.equal(nav.click('/docs/clicked'), true);
+ nav.pop('/docs/back');
+ assert.equal(nav.requests.length, 2);
+ nav.respond(1, { name: 'back' });
+ nav.respond(0, { name: 'clicked' });
+ await tick();
+ assert.equal(nav.content.innerHTML, 'back');
+ assert.deepEqual(nav.pushes, []);
+});
+
+for (const failure of ['network', 'HTTP', 'body']) {
+ test(`a stale ${failure} failure cannot redirect away from the latest navigation`, async () => {
+ const nav = navigation();
+ nav.pop('/docs/first');
+ nav.pop('/docs/latest');
+ assert.equal(nav.requests.length, 2);
+ nav.respond(1, { name: 'latest' });
+ await tick();
+ if (failure === 'network') nav.requests[0].reject(new Error('offline'));
+ else if (failure === 'body') nav.requests[0].resolve({
+ ok: true, headers: { get: () => 'text/html' }, text: async () => { throw new Error('body failed'); },
+ });
+ else nav.respond(0, {}, { ok: false });
+ await tick();
+ assert.deepEqual(nav.redirects, []);
+ assert.equal(nav.content.innerHTML, 'latest');
+ });
+}
+
+test('superseded stylesheet loads are removed without removing the current page styles', async () => {
+ const nav = navigation({ holdStyles: true });
+ nav.pop('/docs/first');
+ nav.respond(0, { name: 'first', styles: ['/first.css'] });
+ await tick();
+ nav.pop('/docs/latest');
+ assert.equal(nav.requests.length, 2);
+ nav.respond(1, { name: 'latest', styles: ['/latest.css'] });
+ await tick();
+ const latest = nav.links.find(link => link.href === '/latest.css');
+ latest.loaded();
+ await tick();
+ nav.links.find(link => link.href === '/first.css').loaded();
+ await tick();
+ assert.equal(nav.content.innerHTML, 'latest');
+ assert.deepEqual(nav.links.filter(link => !link.removed).map(link => link.href), ['/latest.css']);
+});
+
+test('pending page scripts finish before the next page replaces their DOM', async () => {
+ const nav = navigation({ holdScripts: true });
+ nav.pop('/docs/first');
+ nav.respond(0, { name: 'first', scripts: ['/first.js', '/obsolete.js'] });
+ await tick();
+ nav.pop('/docs/latest');
+ assert.equal(nav.requests.length, 2);
+ nav.respond(1, { name: 'latest' });
+ await tick();
+ assert.equal(nav.content.innerHTML, 'first');
+ nav.scripts[0].loaded();
+ await tick();
+ assert.equal(nav.content.innerHTML, 'latest');
+ assert.ok(!nav.scripts.some(script => script.src === '/obsolete.js'));
+});
+
+test('ordinary clicked navigation still pushes history and updates the page', async () => {
+ const nav = navigation();
+ assert.equal(nav.click('/docs/new'), true);
+ nav.respond(0, { name: 'new', scripts: ['/new.js'], styles: ['/new.css'] });
+ await tick();
+ assert.equal(nav.content.innerHTML, 'new');
+ assert.deepEqual(nav.pushes, ['https://example.test/docs/new']);
+ assert.equal(nav.scripts.length, 1);
+});
+
+test('a second clicked link supersedes the first without a stale history push', async () => {
+ const nav = navigation();
+ assert.equal(nav.click('/docs/first'), true);
+ assert.equal(nav.click('/docs/latest'), true);
+ nav.respond(1, { name: 'latest' });
+ await tick();
+ nav.respond(0, { name: 'first' });
+ await tick();
+ assert.equal(nav.content.innerHTML, 'latest');
+ assert.deepEqual(nav.pushes, ['https://example.test/docs/latest']);
+});
+
+test('an active navigation failure still falls back to the browser', async () => {
+ const nav = navigation();
+ nav.pop('/docs/unavailable');
+ nav.requests[0].reject(new Error('offline'));
+ await tick();
+ assert.deepEqual(nav.redirects, ['https://example.test/docs/unavailable']);
+});
From cbefcca9d31a21c4bf28836176c93e9ed1bcb2a9 Mon Sep 17 00:00:00 2001
From: somepoi <46904988+somepoi@users.noreply.github.com>
Date: Sat, 10 Oct 2026 18:21:32 +0300
Subject: [PATCH 21/27] fix: keep audio playback available when browser storage
is denied
---
static/js/player.js | 18 ++++--
tests/player_storage.test.mjs | 106 ++++++++++++++++++++++++++++++++++
2 files changed, 118 insertions(+), 6 deletions(-)
create mode 100644 tests/player_storage.test.mjs
diff --git a/static/js/player.js b/static/js/player.js
index b48988b..c8721e4 100644
--- a/static/js/player.js
+++ b/static/js/player.js
@@ -19,6 +19,11 @@
if (!clearPlayerState || !readPlayerState || !shouldClearOnPageHide || !writePlayerState) return;
+ let volumeStorage = null;
+ let playerStorage = null;
+ try { volumeStorage = localStorage; } catch (error) {}
+ try { playerStorage = sessionStorage; } catch (error) {}
+
let buttons = [];
let status = document.getElementById('res-copy-status');
@@ -32,7 +37,8 @@
const audio = new Audio();
audio.preload = 'none';
- const storedVolume = parseFloat(localStorage.getItem('es-doc-volume') ?? '1');
+ let storedVolume = 1;
+ try { storedVolume = parseFloat(volumeStorage?.getItem('es-doc-volume') ?? '1'); } catch (error) {}
audio.volume = Number.isFinite(storedVolume) ? Math.min(Math.max(storedVolume, 0), 1) : 1;
let current = null;
@@ -99,7 +105,7 @@
const persist = (playing = !audio.paused, time = audio.currentTime) => {
if (!currentSrc) return;
- writePlayerState(sessionStorage, {
+ writePlayerState(playerStorage, {
src: currentSrc,
name: currentName,
time: Number.isFinite(time) && time >= 0 ? time : 0,
@@ -151,7 +157,7 @@
const setVolume = value => {
const volume = Math.min(Math.max(value, 0), 1);
audio.volume = volume;
- localStorage.setItem('es-doc-volume', String(volume));
+ try { volumeStorage?.setItem('es-doc-volume', String(volume)); } catch (error) {}
barVolume.value = volume;
setFill(barVolume);
};
@@ -167,7 +173,7 @@
currentSrc = '';
currentName = '';
finished = false;
- clearPlayerState(sessionStorage);
+ clearPlayerState(playerStorage);
};
const finish = () => {
@@ -256,7 +262,7 @@
bindButtons();
- const saved = readPlayerState(sessionStorage);
+ const saved = readPlayerState(playerStorage);
if (saved) {
current = buttons.find(button => button.dataset.playSrc === saved.src) || null;
currentSrc = saved.src;
@@ -284,7 +290,7 @@
// object alive without a gap (especially important in Firefox).
if (shouldClearOnPageHide(event)) {
audio.pause();
- clearPlayerState(sessionStorage);
+ clearPlayerState(playerStorage);
} else {
persist(!audio.paused);
}
diff --git a/tests/player_storage.test.mjs b/tests/player_storage.test.mjs
new file mode 100644
index 0000000..2eea3b2
--- /dev/null
+++ b/tests/player_storage.test.mjs
@@ -0,0 +1,106 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import vm from 'node:vm';
+
+const load = name => readFileSync(new URL('../static/js/' + name, import.meta.url), 'utf8');
+
+class Element {
+ constructor() {
+ this.attrs = {}; this.dataset = {}; this.handlers = {}; this.children = new Map();
+ this.style = { setProperty() {} };
+ const classes = new Set();
+ this.classList = {
+ add: (...names) => names.forEach(name => classes.add(name)),
+ remove: (...names) => names.forEach(name => classes.delete(name)),
+ contains: name => classes.has(name),
+ };
+ }
+ addEventListener(name, fn) { (this.handlers[name] ||= []).push(fn); }
+ emit(name, event = {}) { (this.handlers[name] || []).forEach(fn => fn(event)); }
+ setAttribute(name, value) { this.attrs[name] = value; }
+ querySelector(selector) {
+ if (!this.children.has(selector)) this.children.set(selector, new Element());
+ return this.children.get(selector);
+ }
+ contains() { return false; }
+ matches() { return false; }
+}
+
+function storage(values = {}, fails) {
+ const items = new Map(Object.entries(values));
+ const check = operation => { if (fails === operation) throw new Error('Storage denied'); };
+ return {
+ getItem(key) { check('getItem'); return items.get(key) ?? null; },
+ setItem(key, value) { check('setItem'); items.set(key, value); },
+ removeItem(key) { check('removeItem'); items.delete(key); },
+ items,
+ };
+}
+
+function player(local, session, deniedGetter) {
+ const button = new Element();
+ button.hidden = true;
+ button.dataset = { playSrc: '/resource/audio/test.ogg', playName: 'Test track' };
+ let audio;
+ class Audio extends Element {
+ constructor() { super(); audio = this; this.paused = true; this.currentTime = 0; this.duration = 120; }
+ play() { this.paused = false; this.emit('play'); return Promise.resolve(); }
+ pause() { this.paused = true; this.emit('pause'); }
+ }
+ const bars = [], status = {}, window = new Element();
+ const document = {
+ getElementById: () => status,
+ createElement: () => new Element(),
+ querySelectorAll: selector => selector === '[data-play-src]' ? [button] : [],
+ body: { appendChild: item => bars.push(item) },
+ };
+ const context = vm.createContext({ window, document, Audio, localStorage: local, sessionStorage: session });
+ if (deniedGetter) Object.defineProperty(context, deniedGetter, {
+ get() { throw new Error('SecurityError: storage access denied'); },
+ });
+ vm.runInContext(load('icons.js'), context);
+ vm.runInContext(load('player-state.js'), context);
+ vm.runInContext(load('player.js'), context);
+ return { audio, button, bar: bars[0], window, status };
+}
+
+for (const target of ['localStorage', 'sessionStorage']) {
+ for (const failure of ['getter', 'getItem', 'setItem', 'removeItem']) {
+ test(`audio controls work when ${target} ${failure} is denied`, () => {
+ const local = storage({}, target === 'localStorage' ? failure : undefined);
+ const session = storage({}, target === 'sessionStorage' ? failure : undefined);
+ const ui = player(local, session, failure === 'getter' ? target : undefined);
+ assert.equal(ui.button.hidden, false);
+ ui.button.emit('click');
+ assert.equal(ui.audio.paused, false);
+ assert.equal(ui.button.attrs['aria-pressed'], 'true');
+ assert.equal(ui.bar.classList.contains('res-nowplaying--visible'), true);
+ const volume = ui.bar.querySelector('.res-volume input');
+ volume.value = '0.35';
+ volume.emit('input');
+ assert.equal(ui.audio.volume, 0.35);
+ assert.equal(volume.value, 0.35);
+ ui.bar.querySelector('.res-nowplaying-pause').emit('click');
+ assert.equal(ui.audio.paused, true);
+ ui.window.emit('pagehide', { persisted: false });
+ });
+ }
+}
+
+test('available storage still restores volume and track position and persists playback', () => {
+ const local = storage({ 'es-doc-volume': '0.25' });
+ const session = storage({ 'es-doc-player-v2': JSON.stringify({
+ src: '/resource/audio/test.ogg', name: 'Saved track', time: 15, playing: true, repeat: true,
+ }) });
+ const ui = player(local, session);
+ assert.equal(ui.audio.volume, 0.25);
+ assert.equal(ui.audio.paused, false);
+ assert.equal(ui.audio.loop, true);
+ ui.audio.emit('loadedmetadata');
+ assert.equal(ui.audio.currentTime, 15);
+ ui.audio.emit('timeupdate');
+ assert.equal(JSON.parse(session.items.get('es-doc-player-v2')).time, 15);
+ ui.window.emit('pagehide', { persisted: false });
+ assert.equal(session.items.has('es-doc-player-v2'), false);
+});
From 6e19fed7cafed115603f01ba78b30be802b9de06 Mon Sep 17 00:00:00 2001
From: somepoi <46904988+somepoi@users.noreply.github.com>
Date: Sat, 10 Oct 2026 18:22:33 +0300
Subject: [PATCH 22/27] fix: dispose article listeners and observers during
navigation
---
static/js/docs.js | 45 +++++++++----
static/js/navigation.js | 1 +
tests/docs_lifecycle.test.mjs | 117 ++++++++++++++++++++++++++++++++++
tests/navigation.test.mjs | 13 +++-
4 files changed, 164 insertions(+), 12 deletions(-)
create mode 100644 tests/docs_lifecycle.test.mjs
diff --git a/static/js/docs.js b/static/js/docs.js
index 4d5fa8e..c996b09 100644
--- a/static/js/docs.js
+++ b/static/js/docs.js
@@ -1,5 +1,22 @@
/* Documentation navigation; native disclosures also work without scripts. */
(function () {
+ window.__esdocDocsCleanup?.();
+ const controller = new AbortController();
+ const signal = controller.signal;
+ let disposed = false;
+ let frame = 0;
+ let navigationFrame = 0;
+ let observer;
+ window.__esdocDocsCleanup = () => {
+ if (disposed) return;
+ disposed = true;
+ controller.abort();
+ observer?.disconnect();
+ cancelAnimationFrame(frame);
+ cancelAnimationFrame(navigationFrame);
+ window.__esdocDocsCleanup = null;
+ };
+
const tree = document.getElementById('sidebar-all');
const contents = document.getElementById('sidebar-contents');
const desktopTree = matchMedia('(min-width: 62em)');
@@ -11,20 +28,22 @@
const hadFocus = details.contains(document.activeElement);
details.open = media.matches;
if (hadFocus && !details.open) details.querySelector('summary').focus({preventScroll: true});
- });
+ }, {signal});
details.addEventListener('keydown', event => {
if (event.key !== 'Escape' || !details.open) return;
details.open = false;
details.querySelector('summary').focus({preventScroll: true});
- });
+ }, {signal});
}
adaptDisclosure(tree, desktopTree);
adaptDisclosure(contents, desktopContents);
if (contents && window.ResizeObserver) {
const summary = contents.querySelector('summary');
- new ResizeObserver(() => {
+ observer = new ResizeObserver(() => {
+ if (disposed) return;
document.documentElement.style.setProperty('--doc-contents-h', summary.offsetHeight + 'px');
- }).observe(summary);
+ });
+ observer.observe(summary);
}
// Scroll only the independent rail; preserve the document's deep link.
@@ -45,8 +64,8 @@
const headings = [...document.querySelectorAll('.content .heading')].filter(heading => links.has(heading.id));
if (!headings.length) return;
let active = null;
- let frame = 0;
function update() {
+ if (disposed) return;
frame = 0;
const offset = (parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 64) + 24;
let heading;
@@ -81,12 +100,13 @@
active.setAttribute('aria-current', 'location');
}
function scheduleUpdate() {
+ if (disposed) return;
if (!frame) frame = requestAnimationFrame(update);
}
- addEventListener('scroll', scheduleUpdate, {passive: true});
- addEventListener('resize', scheduleUpdate);
- addEventListener('hashchange', scheduleUpdate);
- addEventListener('load', scheduleUpdate);
+ addEventListener('scroll', scheduleUpdate, {passive: true, signal});
+ addEventListener('resize', scheduleUpdate, {signal});
+ addEventListener('hashchange', scheduleUpdate, {signal});
+ addEventListener('load', scheduleUpdate, {signal});
if (document.fonts) document.fonts.ready.then(scheduleUpdate);
update();
@@ -100,12 +120,15 @@
if (!target) return;
event.preventDefault();
contents.open = false;
- requestAnimationFrame(() => {
+ cancelAnimationFrame(navigationFrame);
+ navigationFrame = requestAnimationFrame(() => {
+ navigationFrame = 0;
+ if (disposed) return;
history.pushState(null, '', link.hash);
target.setAttribute('tabindex', '-1');
target.focus({preventScroll: true});
target.scrollIntoView({block: 'start', behavior: 'auto'});
scheduleUpdate();
});
- });
+ }, {signal});
})();
diff --git a/static/js/navigation.js b/static/js/navigation.js
index 5163264..49548c8 100644
--- a/static/js/navigation.js
+++ b/static/js/navigation.js
@@ -93,6 +93,7 @@
await updateStyles(nextDocument, signal);
if (id !== navigationId) return;
+ window.__esdocDocsCleanup?.();
window.__esdocWarperCleanup?.();
window.__esdocToolsCleanup?.();
window.__esdocColorsCleanup?.();
diff --git a/tests/docs_lifecycle.test.mjs b/tests/docs_lifecycle.test.mjs
new file mode 100644
index 0000000..df62e97
--- /dev/null
+++ b/tests/docs_lifecycle.test.mjs
@@ -0,0 +1,117 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import vm from 'node:vm';
+
+const source = readFileSync(new URL('../static/js/docs.js', import.meta.url), 'utf8');
+const tick = () => new Promise(resolve => setImmediate(resolve));
+const emit = (target, name, properties = {}) => {
+ const event = new Event(name);
+ for (const [name, value] of Object.entries(properties)) Object.defineProperty(event, name, { value });
+ target.dispatchEvent(event);
+};
+
+function docs() {
+ const window = new EventTarget(), frames = new Map(), media = [], observers = [], css = [], pushes = [];
+ let frameId = 0, currentPage, resolveFonts;
+ class ResizeObserver {
+ constructor(callback) { this.callback = callback; observers.push(this); }
+ observe() {}
+ disconnect() { this.disconnected = true; }
+ }
+ window.ResizeObserver = ResizeObserver;
+ const context = vm.createContext({
+ window, ResizeObserver, AbortController, location: { hash: '' }, innerHeight: 1000, scrollY: 0,
+ addEventListener: window.addEventListener.bind(window),
+ requestAnimationFrame: fn => { const id = ++frameId; frames.set(id, fn); return id; },
+ cancelAnimationFrame: id => frames.delete(id),
+ getComputedStyle: () => ({ scrollPaddingTop: '64px' }),
+ matchMedia: () => { const item = new EventTarget(); item.matches = false; media.push(item); return item; },
+ history: { pushState: (...args) => pushes.push(args) },
+ document: {
+ getElementById: id => ({ 'sidebar-all': currentPage.tree, 'sidebar-contents': currentPage.contents,
+ section: currentPage.heading })[id],
+ querySelectorAll: selector => selector.startsWith('.sidebar-toc') ? currentPage.links : currentPage.headings,
+ documentElement: { scrollHeight: 2000, style: { setProperty: (...args) => css.push(args) } },
+ fonts: { ready: new Promise(resolve => { resolveFonts = resolve; }) },
+ },
+ });
+ function page(hasHeadings = true) {
+ const summary = { offsetHeight: 32, focus() {} };
+ const tree = new EventTarget(), contents = new EventTarget();
+ for (const details of [tree, contents]) {
+ details.open = false;
+ details.contains = () => false;
+ details.querySelector = selector => selector === 'summary' ? summary : null;
+ details.closest = () => null;
+ }
+ const link = { hash: '#section', classList: { add() {}, remove() {} }, setAttribute() {}, removeAttribute() {} };
+ const result = {
+ tree, contents, reads: 0, links: hasHeadings ? [link] : [],
+ heading: { id: 'section',
+ getBoundingClientRect() { result.reads++; return { top: 0 }; },
+ setAttribute() {}, focus() {}, scrollIntoView() {},
+ },
+ };
+ result.headings = hasHeadings ? [result.heading] : [];
+ currentPage = result;
+ vm.runInContext(source, context);
+ result.reads = 0;
+ return result;
+ }
+ function flush() {
+ const callbacks = [...frames.values()]; frames.clear(); callbacks.forEach(fn => fn());
+ }
+ return { window, page, flush, frames, media, observers, css, pushes, resolveFonts };
+}
+
+test('repeated article navigation retains only the current scroll handler', () => {
+ const site = docs(), pages = [];
+ for (let i = 0; i < 12; i++) pages.push(site.page());
+ emit(site.window, 'scroll');
+ site.flush();
+ assert.ok(pages.at(-1).reads > 0);
+ assert.ok(pages.slice(0, -1).every(page => page.reads === 0), 'Old articles still inspect their headings');
+ assert.ok(site.observers.slice(0, -1).every(observer => observer.disconnected));
+});
+
+test('explicit cleanup removes global and media handlers and disconnects the observer', () => {
+ const site = docs(), page = site.page();
+ assert.equal(typeof site.window.__esdocDocsCleanup, 'function');
+ site.window.__esdocDocsCleanup();
+ for (const name of ['scroll', 'resize', 'hashchange', 'load']) emit(site.window, name);
+ for (const media of site.media) { media.matches = true; emit(media, 'change'); }
+ site.flush();
+ assert.equal(page.reads, 0);
+ assert.equal(page.tree.open, false);
+ assert.equal(page.contents.open, false);
+ assert.equal(site.observers[0].disconnected, true);
+ const writes = site.css.length;
+ site.observers[0].callback();
+ assert.equal(site.css.length, writes);
+});
+
+test('pages without headings still clean up their disclosure handlers and observer', () => {
+ const site = docs(), page = site.page(false);
+ assert.equal(typeof site.window.__esdocDocsCleanup, 'function');
+ site.window.__esdocDocsCleanup();
+ for (const media of site.media) { media.matches = true; emit(media, 'change'); }
+ assert.equal(page.tree.open, false);
+ assert.equal(page.contents.open, false);
+ assert.equal(site.observers[0].disconnected, true);
+});
+
+test('cleanup cancels scroll frames and prevents deferred font and TOC callbacks', async () => {
+ const site = docs(), page = site.page();
+ emit(site.window, 'scroll');
+ emit(page.contents, 'click', { button: 0, target: { closest: () => page.links[0] } });
+ assert.ok(site.frames.size > 0);
+ assert.equal(typeof site.window.__esdocDocsCleanup, 'function');
+ site.window.__esdocDocsCleanup();
+ site.flush();
+ site.resolveFonts();
+ await tick();
+ assert.equal(site.frames.size, 0);
+ assert.equal(page.reads, 0);
+ assert.deepEqual(site.pushes, []);
+});
diff --git a/tests/navigation.test.mjs b/tests/navigation.test.mjs
index 98236e4..cf5259c 100644
--- a/tests/navigation.test.mjs
+++ b/tests/navigation.test.mjs
@@ -63,7 +63,7 @@ function navigation({ holdStyles = false, holdScripts = false } = {}) {
CustomEvent: class {}, localStorage: { getItem: () => null },
});
return {
- content, document, location, requests, links, scripts, pushes, redirects,
+ content, document, location, requests, links, scripts, pushes, redirects, window,
pop(path) { current = new URL(path, current); events.popstate(); },
click(path) {
let prevented = false;
@@ -188,3 +188,14 @@ test('an active navigation failure still falls back to the browser', async () =>
await tick();
assert.deepEqual(nav.redirects, ['https://example.test/docs/unavailable']);
});
+
+test('article handlers are cleaned up before replacing the page content', async () => {
+ const nav = navigation();
+ let oldContent;
+ nav.window.__esdocDocsCleanup = () => { oldContent = nav.content.innerHTML; };
+ nav.pop('/docs/new');
+ nav.respond(0, { name: 'new' });
+ await tick();
+ assert.equal(oldContent, 'initial');
+ assert.equal(nav.content.innerHTML, 'new');
+});
From 986e139aff23b303782b02d5962bd1a47a167247 Mon Sep 17 00:00:00 2001
From: somepoi <46904988+somepoi@users.noreply.github.com>
Date: Sat, 10 Oct 2026 18:34:17 +0300
Subject: [PATCH 23/27] fix: preserve selected history URLs when page scripts
finish late
---
static/js/navigation.js | 1 +
tests/navigation.test.mjs | 22 +++++++++++++++++++++-
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/static/js/navigation.js b/static/js/navigation.js
index 49548c8..127382d 100644
--- a/static/js/navigation.js
+++ b/static/js/navigation.js
@@ -115,6 +115,7 @@
updateNavigation(url);
if (pushHistory) history.pushState({}, '', url.href);
+ else if (location.href !== url.href) history.replaceState(history.state, '', url.href);
if (!url.hash) window.scrollTo(0, 0);
await loadPageScripts(nextDocument, id);
diff --git a/tests/navigation.test.mjs b/tests/navigation.test.mjs
index cf5259c..b8a97f5 100644
--- a/tests/navigation.test.mjs
+++ b/tests/navigation.test.mjs
@@ -45,7 +45,10 @@ function navigation({ holdStyles = false, holdScripts = false } = {}) {
fetch, DOMParser: true, scrollTo() {}, dispatchEvent() {},
addEventListener: (name, fn) => { events[name] = fn; },
};
- const history = { pushState(state, title, url) { pushes.push(url); current = new URL(url); } };
+ const history = {
+ pushState(state, title, url) { pushes.push(url); current = new URL(url); },
+ replaceState(state, title, url) { current = new URL(url); },
+ };
class DOMParser {
parseFromString(text) {
const page = JSON.parse(text);
@@ -64,6 +67,7 @@ function navigation({ holdStyles = false, holdScripts = false } = {}) {
});
return {
content, document, location, requests, links, scripts, pushes, redirects, window,
+ replace(url) { current = new URL(url, current); },
pop(path) { current = new URL(path, current); events.popstate(); },
click(path) {
let prevented = false;
@@ -159,6 +163,22 @@ test('pending page scripts finish before the next page replaces their DOM', asyn
assert.ok(!nav.scripts.some(script => script.src === '/obsolete.js'));
});
+test('a finishing script cannot leave its query parameters on the selected history entry', async () => {
+ const nav = navigation({ holdScripts: true });
+ nav.pop('/resources/original/bg');
+ nav.respond(0, { name: 'resources', scripts: ['/resources.js'] });
+ await tick();
+ nav.pop('/docs/latest');
+ nav.respond(1, { name: 'latest' });
+ await tick();
+ // Resource filters use replaceState while their script initializes.
+ nav.replace('/docs/latest?time=night');
+ nav.scripts[0].loaded();
+ await tick();
+ assert.equal(nav.content.innerHTML, 'latest');
+ assert.equal(nav.location.href, 'https://example.test/docs/latest');
+});
+
test('ordinary clicked navigation still pushes history and updates the page', async () => {
const nav = navigation();
assert.equal(nav.click('/docs/new'), true);
From f9fe4e58fbc04f5b0c9de1ebb442a53c25920368 Mon Sep 17 00:00:00 2001
From: somepoi <46904988+somepoi@users.noreply.github.com>
Date: Sat, 10 Oct 2026 18:38:43 +0300
Subject: [PATCH 24/27] test: verify application regressions in native browsers
---
tests/app_regressions_browser.cjs | 141 ++++++++++++++++++++++++++++++
tests/create_tools_fixtures.py | 14 +++
tests/tools_browser.cjs | 38 +++++---
3 files changed, 182 insertions(+), 11 deletions(-)
create mode 100644 tests/app_regressions_browser.cjs
diff --git a/tests/app_regressions_browser.cjs b/tests/app_regressions_browser.cjs
new file mode 100644
index 0000000..9d2afa3
--- /dev/null
+++ b/tests/app_regressions_browser.cjs
@@ -0,0 +1,141 @@
+/* Optional native-browser regressions. Start ES Doc and set NODE_PATH to
+ Playwright; APP_TEST_URL defaults to http://127.0.0.1:8012. */
+const assert = require('node:assert/strict');
+const {chromium} = require('playwright');
+const origin = process.env.APP_TEST_URL || 'http://127.0.0.1:8012';
+
+const softNavigate = async (page, path) => {
+ await page.evaluate(href => {
+ const link = document.createElement('a');
+ link.href = href;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ }, path);
+ await page.waitForURL(origin + path);
+ await page.waitForFunction(href => window.__lastRegressionNavigation === href, origin + path);
+};
+
+(async () => {
+ const browser = await chromium.launch({headless: true});
+ try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.addInitScript(() => {
+ window.__regressionDocument = 'original';
+ window.__regressionListeners = new Set();
+ window.__regressionObservers = new Set();
+ const originalAdd = window.addEventListener;
+ window.addEventListener = function (type, callback, options) {
+ if (options?.signal && ['scroll', 'resize', 'hashchange', 'load'].includes(type)) {
+ const record = {type};
+ window.__regressionListeners.add(record);
+ options.signal.addEventListener('abort', () => window.__regressionListeners.delete(record), {once: true});
+ }
+ return originalAdd.call(this, type, callback, options);
+ };
+ const OriginalObserver = window.ResizeObserver;
+ window.ResizeObserver = class extends OriginalObserver {
+ constructor(callback) { super(callback); window.__regressionObservers.add(this); }
+ observe(target, options) { this.target = target; super.observe(target, options); }
+ disconnect() { window.__regressionObservers.delete(this); super.disconnect(); }
+ };
+ originalAdd.call(window, 'esdoc:navigation', event => {
+ window.__lastRegressionNavigation = event.detail.url;
+ });
+ });
+ await page.goto(origin + '/docs/variables');
+ await page.waitForFunction(() => typeof window.__esdocDocsCleanup === 'function');
+ await page.evaluate(() => window.__regressionDocument = 'retained');
+ for (const slug of ['screens', 'videos', 'variables', 'screens', 'videos']) {
+ await softNavigate(page, '/docs/' + slug);
+ const live = await page.evaluate(() => ({
+ document: window.__regressionDocument,
+ listeners: window.__regressionListeners.size,
+ observers: [...window.__regressionObservers].filter(observer => observer.target?.closest('#sidebar-contents')).length,
+ }));
+ assert.deepEqual(live, {document: 'retained', listeners: 4, observers: 1});
+ }
+
+ // Two real Back actions while the first HTTP request is held open.
+ let heldRoute, announceRequest;
+ const started = new Promise(resolve => { announceRequest = resolve; });
+ await page.route('**/docs/screens', route => { heldRoute = route; announceRequest(); });
+ await page.evaluate(() => history.back());
+ await started;
+ await page.evaluate(() => history.back());
+ await page.waitForURL(origin + '/docs/variables');
+ await page.waitForFunction(() => document.querySelector('#sidebar-all a[aria-current="page"]')
+ ?.getAttribute('href') === '/docs/variables');
+ await heldRoute.abort().catch(() => {});
+ await page.unroute('**/docs/screens');
+ assert.equal(new URL(page.url()).pathname, '/docs/variables');
+ assert.equal(await page.evaluate(() => window.__regressionDocument), 'retained');
+
+ await softNavigate(page, '/materials');
+ assert.deepEqual(await page.evaluate(() => ({
+ listeners: window.__regressionListeners.size,
+ observers: [...window.__regressionObservers].filter(observer => observer.target?.closest('#sidebar-contents')).length,
+ })), {listeners: 0, observers: 0});
+
+ // Pending search results must remain hidden after clearing the field.
+ let searchRoute, announceSearch;
+ const searchStarted = new Promise(resolve => { announceSearch = resolve; });
+ await page.route('**/api/search?**', route => { searchRoute = route; announceSearch(); });
+ await page.locator('#site-search-input').fill('Луна');
+ await searchStarted;
+ await page.locator('#site-search-input').fill('');
+ await searchRoute.fulfill({json: [{label: 'Луна', url: '/stale'}]});
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ assert.equal(await page.locator('#site-search-results').isHidden(), true);
+ assert.equal(await page.locator('#site-search-input').getAttribute('aria-expanded'), 'false');
+ assert.deepEqual(errors, []);
+ await page.close();
+ console.log('Native browser checks passed: article cleanup, overlapping Back actions and dismissed search.');
+
+ // A small valid WAV exercises real Audio playback with both stores denied.
+ const samples = 8000 * 8, wav = Buffer.alloc(44 + samples * 2);
+ wav.write('RIFF'); wav.writeUInt32LE(wav.length - 8, 4); wav.write('WAVEfmt ', 8);
+ wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
+ wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32);
+ wav.writeUInt16LE(16, 34); wav.write('data', 36); wav.writeUInt32LE(samples * 2, 40);
+ for (const failure of ['getter', 'methods']) {
+ const playerPage = await browser.newPage();
+ const playerErrors = [];
+ playerPage.on('pageerror', error => playerErrors.push(error.message));
+ await playerPage.addInitScript(failure => {
+ for (const name of ['localStorage', 'sessionStorage']) Object.defineProperty(window, name, {
+ configurable: true,
+ get() {
+ if (failure === 'getter') throw new DOMException('Storage denied', 'SecurityError');
+ return {getItem() { throw new Error('denied'); }, setItem() { throw new Error('denied'); },
+ removeItem() { throw new Error('denied'); }};
+ },
+ });
+ }, failure);
+ await playerPage.route('**/regression.wav', route => route.fulfill({body: wav, contentType: 'audio/wav'}));
+ await playerPage.goto(origin + '/');
+ await playerPage.evaluate(() => {
+ const button = document.createElement('button');
+ button.id = 'regression-play'; button.hidden = true;
+ button.dataset.playSrc = '/regression.wav'; button.dataset.playName = 'Regression audio';
+ button.textContent = 'Play'; document.body.appendChild(button);
+ window.dispatchEvent(new CustomEvent('esdoc:navigation'));
+ });
+ await playerPage.locator('#regression-play').click();
+ await playerPage.waitForFunction(() => document.querySelector('#regression-play').getAttribute('aria-pressed') === 'true');
+ assert.equal(await playerPage.locator('.res-nowplaying').isVisible(), true);
+ await playerPage.locator('.res-volume input').evaluate(input => {
+ input.value = '0.35'; input.dispatchEvent(new Event('input', {bubbles: true}));
+ });
+ assert.equal(await playerPage.locator('.res-volume input').inputValue(), '0.35');
+ await playerPage.locator('.res-nowplaying-pause').click();
+ await playerPage.waitForFunction(() => document.querySelector('#regression-play').getAttribute('aria-pressed') === 'false');
+ assert.equal(await playerPage.locator('#regression-play').getAttribute('aria-pressed'), 'false');
+ assert.deepEqual(playerErrors, []);
+ await playerPage.close();
+ }
+ console.log('Native audio checks passed with storage getters and methods denied.');
+ } finally { await browser.close(); }
+})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/tests/create_tools_fixtures.py b/tests/create_tools_fixtures.py
index b396293..e7add2e 100644
--- a/tests/create_tools_fixtures.py
+++ b/tests/create_tools_fixtures.py
@@ -1,5 +1,7 @@
"""Synthetic Ren'Py/RPA fixtures for the optional real-browser smoke test."""
from pathlib import Path
+import pickle
+import zlib
from tests.test_tools_engine import EngineTests, archive
@@ -17,6 +19,18 @@ def main():
(root / 'bad.rpyc').write_bytes(b'not a compiled RenPy script')
(root / 'images.rpa').write_bytes(archive({'images/test.txt': [(b'second archive', b'')]}))
+ # Seek over a large payload so fixture creation does not allocate it in RAM.
+ length, key = 65 * 1024 * 1024, 0xDEADBEEF
+ index_offset = 34 + length + 2
+ with (root / 'large.rpa').open('wb') as output:
+ output.write(f'RPA-3.0 {index_offset:016x} {key:08x}\n'.encode())
+ output.seek(34 + length)
+ output.write(b'ok')
+ output.write(zlib.compress(pickle.dumps({
+ 'big.bin': [(34 ^ key, length ^ key, b'')],
+ 'small.txt': [((34 + length) ^ key, 2 ^ key, b'')],
+ }, protocol=2)))
+
if __name__ == '__main__':
main()
diff --git a/tests/tools_browser.cjs b/tests/tools_browser.cjs
index 879b364..6ec19a5 100644
--- a/tests/tools_browser.cjs
+++ b/tests/tools_browser.cjs
@@ -1,13 +1,13 @@
/* Optional integration test: run create_tools_fixtures first, start ES Doc,
set NODE_PATH to your installed Playwright, then node tests/tools_browser.cjs.
- TOOLS_TEST_URL defaults to http://127.0.0.1:8012/tools. */
+ TOOLS_TEST_URL defaults to http://127.0.0.1:8012/tools/unpack. */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const root = path.resolve(__dirname, '..');
const fixtures = path.join(root, 'temp/tools-fixtures');
-const target = process.env.TOOLS_TEST_URL || 'http://127.0.0.1:8012/tools';
+const target = process.env.TOOLS_TEST_URL || 'http://127.0.0.1:8012/tools/unpack';
const origin = new URL(target).origin;
(async () => {
@@ -15,6 +15,8 @@ const origin = new URL(target).origin;
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, colorScheme: 'light' });
const errors = [], requests = [];
+ let batchDownloads = [];
+ page.on('download', download => batchDownloads.push(download));
page.on('pageerror', error => errors.push(error.message));
page.on('request', request => requests.push([request.method(), request.url()]));
await page.goto(target);
@@ -35,19 +37,22 @@ const origin = new URL(target).origin;
await page.keyboard.press('Escape');
const finish = async () => {
- await page.locator('#tools-download-all').click();
+ batchDownloads = [];
+ const download = page.waitForEvent('download', {timeout: 90000});
+ await page.locator('#tools-export-start').click();
await page.locator('#tools-result').waitFor({ state: 'visible', timeout: 90000 });
await page.waitForFunction(() => document.querySelector('#tools-downloads a')
&& document.querySelector('#tools-browser').getAttribute('aria-busy') === 'false', undefined, { timeout: 90000 });
+ await download;
};
const save = async name => {
- const promise = page.waitForEvent('download');
- await page.locator('#tools-downloads a').first().click();
- await (await promise).saveAs(path.join(root, 'temp/' + name));
+ assert(batchDownloads.length, 'Export should start a download automatically');
+ await batchDownloads[0].saveAs(path.join(root, 'temp/' + name));
assert.equal(await page.locator('#tools-queue').isVisible(),
(await page.locator('#tools-result-description').textContent()).includes('Ошибок:'));
};
const clear = async () => {
+ if (await page.locator('#tools-export-dialog').isVisible()) await page.locator('#tools-export-close').click();
if (await page.locator('#tools-clear').isVisible()) await page.locator('#tools-clear').click();
};
@@ -69,7 +74,6 @@ const origin = new URL(target).origin;
// A warm worker processes a recursive directory and restores paths.
await clear();
- await page.locator('input[value="unrpyc"]').check();
await pick('folder', path.join(fixtures, 'game/scenario'));
await page.locator('#tools-browser-search').fill('script.rpyc');
assert.match(await page.locator('#tools-browser-list').textContent(), /scenario\/script.rpyc/);
@@ -77,7 +81,6 @@ const origin = new URL(target).origin;
await save('tools-browser-folder.zip');
await clear();
- await page.locator('input[value="unrpa"]').check();
await pick('files', path.join(fixtures, 'game/data.rpa'));
await page.waitForFunction(() => document.querySelector('#tools-browser').getAttribute('aria-busy') === 'false');
assert.equal(await page.locator('#tools-downloads a').count(), 0);
@@ -91,11 +94,21 @@ const origin = new URL(target).origin;
await page.waitForFunction(() => document.querySelector('#tools-browser').getAttribute('aria-busy') === 'false');
await finish();
assert.deepEqual(await page.locator('#tools-downloads a').evaluateAll(links => links.map(link => link.download)), ['data.zip', 'images.zip']);
- assert(await page.locator('#tools-download-all').isVisible());
+ assert.equal(await page.locator('#tools-download-all').isVisible(), false);
+
+ // Listing remains usable after a real Pyodide preview hits its size cap.
+ await clear();
+ await pick('files', path.join(fixtures, 'large.rpa'));
+ await page.waitForFunction(() => document.querySelector('#tools-browser').getAttribute('aria-busy') === 'false');
+ await page.locator('#tools-browser-search').fill('');
+ await page.locator('#tools-browser-list button[data-entry-id]').filter({hasText: 'big.bin'}).click();
+ await page.waitForFunction(() => document.querySelector('#tools-browser-preview').textContent.includes('Скачайте архив'));
+ await page.locator('#tools-browser-list button[data-entry-id]').filter({hasText: 'small.txt'}).click();
+ await page.locator('#tools-browser-preview pre').waitFor({state: 'visible'});
+ assert.equal((await page.locator('#tools-browser-preview .code-scroll').textContent()).trim(), 'ok');
// Drop through a real DataTransfer, alongside a corrupt input.
await clear();
- await page.locator('input[value="unrpa"]').check();
const script = [...fs.readFileSync(path.join(fixtures, 'game/scenario/script.rpyc'))];
await page.evaluate(bytes => {
const transfer = new DataTransfer();
@@ -103,17 +116,20 @@ const origin = new URL(target).origin;
transfer.items.add(new File(['invalid'], 'bad.rpyc'));
document.getElementById('tools-drop').dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: transfer }));
}, script);
- assert(await page.locator('input[value="unrpyc"]').isChecked());
+ assert(await page.locator('#tools-mode-unpack').isChecked());
await finish();
assert.match(await page.locator('#tools-result-description').textContent(), /Ошибок: 1/);
await save('tools-browser-partial.zip');
// Soft navigation must clean up the worker and initialize exactly once.
+ await page.locator('#tools-export-close').click();
await page.locator('.site-header a[href="/materials"]').click();
await page.waitForURL('**/materials');
assert.equal(await page.evaluate(() => window.__esdocToolsCleanup), null);
await page.locator('.site-header a[href="/tools"]').click();
await page.waitForURL('**/tools');
+ await page.locator('#site-content a[href="/tools/unpack"]').click();
+ await page.waitForURL('**/tools/unpack');
await page.locator('#tools-controls').waitFor({ state: 'visible' });
assert.equal(await page.locator('#tools-file-list li').count(), 0);
From 815b858abf8e8adea27807ed54d5cbbc2d2a4140 Mon Sep 17 00:00:00 2001
From: somepoi <46904988+somepoi@users.noreply.github.com>
Date: Sun, 11 Oct 2026 01:56:48 +0300
Subject: [PATCH 25/27] chore: new tools added
---
templates/tools.html | 3 +++
1 file changed, 3 insertions(+)
diff --git a/templates/tools.html b/templates/tools.html
index 12579a6..7460d01 100644
--- a/templates/tools.html
+++ b/templates/tools.html
@@ -23,6 +23,9 @@