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/res.py b/app/routes/res.py
index 9c4b86e..5a2c101 100644
--- a/app/routes/res.py
+++ b/app/routes/res.py
@@ -89,11 +89,11 @@ async def _ensure_tinted(kind, name):
if not source:
raise HTTPException(404, f'Исходный файл для "{kind} {name}" не существует.')
- if not is_tinted(kind, name, source):
+ if not is_tinted(kind, name, source, item['tint']):
async with compose_lock:
# Re-check: the request holding the lock before us may have
# composed this very image.
- if not is_tinted(kind, name, source):
+ if not is_tinted(kind, name, source, item['tint']):
try:
await asyncio.to_thread(compose_tint, kind, name, source, item['tint'])
except Exception:
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/app/routes/search_discovery.py b/app/routes/search_discovery.py
index 09930cb..e73c44c 100644
--- a/app/routes/search_discovery.py
+++ b/app/routes/search_discovery.py
@@ -1,9 +1,9 @@
"""Crawler entry points for the public pages of the site."""
-from urllib.parse import quote
+from urllib.parse import quote, urlsplit, urlunsplit
from xml.etree import ElementTree
-from fastapi import Response
+from fastapi import HTTPException, Response
from fastapi.responses import PlainTextResponse
from ..utils.config import CONFIG
@@ -19,13 +19,36 @@
)
+def _site_origin():
+ value = CONFIG.site_url
+ if not isinstance(value, str) or not value.strip():
+ return None
+ origin = value.strip().rstrip('/')
+ if any(char.isspace() for char in origin):
+ return None
+ try:
+ parsed = urlsplit(origin)
+ if (parsed.scheme not in ('http', 'https') or not parsed.hostname
+ or parsed.username is not None or parsed.password is not None
+ or parsed.path or parsed.query or parsed.fragment):
+ return None
+ parsed.port # Validate the optional port before publishing the URL.
+ return urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))
+ except ValueError:
+ return None
+
+
@main_router.get('/robots.txt', response_class=PlainTextResponse)
async def robots_txt():
- return f'User-agent: *\nAllow: /\n\nSitemap: {CONFIG.site_url}/sitemap.xml\n'
+ origin = _site_origin()
+ return 'User-agent: *\nAllow: /\n' + (f'\nSitemap: {origin}/sitemap.xml\n' if origin else '')
@main_router.get('/sitemap.xml')
async def sitemap_xml():
+ origin = _site_origin()
+ if not origin:
+ raise HTTPException(404, 'Адрес сайта не настроен.')
paths = list(STATIC_PAGES)
paths.extend(f'/docs/{quote(doc["slug"], safe="")}' for doc in CONFIG.search_index)
paths.extend(
@@ -39,7 +62,7 @@ async def sitemap_xml():
root = ElementTree.Element(f'{{{SITEMAP_NS}}}urlset')
for path in dict.fromkeys(paths):
entry = ElementTree.SubElement(root, f'{{{SITEMAP_NS}}}url')
- ElementTree.SubElement(entry, f'{{{SITEMAP_NS}}}loc').text = CONFIG.site_url + path
+ ElementTree.SubElement(entry, f'{{{SITEMAP_NS}}}loc').text = origin + path
return Response(
content=ElementTree.tostring(root, encoding='utf-8', xml_declaration=True),
diff --git a/app/routes/static.py b/app/routes/static.py
index 438af54..7c97bbd 100644
--- a/app/routes/static.py
+++ b/app/routes/static.py
@@ -19,6 +19,19 @@
_VERSIONED_CACHE = 'public, max-age=31536000, immutable'
+def _asset_path(folder: str, name: str):
+ if not name or any(char in name for char in ('/', '\\', ':', '\x00')):
+ raise HTTPException(404, 'Файл не существует.')
+ root = (ROOT / 'static' / folder).resolve()
+ try:
+ path = (root / name).resolve()
+ if path.is_relative_to(root) and path.is_file():
+ return path
+ except (OSError, ValueError):
+ pass
+ raise HTTPException(404, 'Файл не существует.')
+
+
def _text_asset(request: Request, rel: str, media_type: str) -> Response:
try:
body, digest = load_asset(rel)
@@ -44,10 +57,12 @@ async def favicon():
@router.get('/static/css/{name}')
async def css(name, request: Request):
+ _asset_path('css', name)
return _text_asset(request, f'static/css/{name}', 'text/css; charset=utf-8')
@router.get('/static/js/{name}')
async def js(name, request: Request):
+ _asset_path('js', name)
return _text_asset(request, f'static/js/{name}', 'application/javascript; charset=utf-8')
@@ -62,7 +77,7 @@ async def font(name):
# Binary asset: FileResponse, not read_text (which decodes as UTF-8).
# Immutable, content-hashed by weight/subset, so cache hard.
return FileResponse(
- str(ROOT / 'static' / 'fonts' / name),
+ str(_asset_path('fonts', name)),
media_type='font/woff2',
headers={
'Cache-Control': 'public, max-age=31536000, immutable',
diff --git a/app/utils/lifespan/refresh.py b/app/utils/lifespan/refresh.py
index 0d772de..48c129a 100644
--- a/app/utils/lifespan/refresh.py
+++ b/app/utils/lifespan/refresh.py
@@ -105,6 +105,7 @@ def _watchers():
assets / 'nsfw.yaml',
)
community_files = _under(assets / 'community')
+ game_files = _under(res)
materials_yaml = _one_of(assets / 'materials.yaml')
materials_files = _under(assets / 'materials')
@@ -119,7 +120,7 @@ def _watchers():
res / 'scenario' / 'zhenya.rpy',
), _refresh_sprites),
- ('resources', lambda path: resource_sources(path) or community_files(path), _refresh_resources),
+ ('resources', lambda path: resource_sources(path) or community_files(path) or game_files(path), _refresh_resources),
('browser', _under(res), _refresh_browser),
('specialists.yaml', _one_of(assets / 'specialists.yaml'), _refresh_specialists),
diff --git a/app/utils/lifespan/sprites_cache.py b/app/utils/lifespan/sprites_cache.py
index d2b515d..a00cac2 100644
--- a/app/utils/lifespan/sprites_cache.py
+++ b/app/utils/lifespan/sprites_cache.py
@@ -39,11 +39,18 @@ def sprite_file(name):
return sprites_path() / f'{name}.webp'
def is_composed(name):
- # The disk cache survives restarts; a cached sprite is only stale if
- # a declaration source changed after it was composed.
+ # Both the declaration and every layer contribute to the composed bytes.
path = sprite_file(name)
- newest = max(p.stat().st_mtime for p in _sprite_sources() if p.exists())
- return path.is_file() and path.stat().st_mtime >= newest
+ layers = CONFIG.sprite_layers.get(name)
+ if not path.is_file() or not layers:
+ return False
+ sources = [p for p in _sprite_sources() if p.exists()]
+ sources.extend(CONFIG.res_path / layer for layer in layers)
+ try:
+ newest = max(source.stat().st_mtime_ns for source in sources)
+ return path.stat().st_mtime_ns >= newest
+ except OSError:
+ return False
def compose_sprite(name):
diff --git a/app/utils/lifespan/tint_cache.py b/app/utils/lifespan/tint_cache.py
index c2aa85c..995338f 100644
--- a/app/utils/lifespan/tint_cache.py
+++ b/app/utils/lifespan/tint_cache.py
@@ -1,3 +1,4 @@
+import json
import os, time
from PIL import Image
@@ -12,17 +13,29 @@ def tinted_path():
def tinted_file(kind, name):
return tinted_path() / kind / f'{name}.webp'
-def is_tinted(kind, name, source):
- # The disk cache survives restarts; a cached tint is only stale if the
- # source image changed after it was composed.
+def _signature(source, tint):
+ stat = source.stat()
+ return [str(source.resolve()), stat.st_mtime_ns, stat.st_size,
+ list(tint), CONFIG.setting('images.tint-quality')]
+
+
+def is_tinted(kind, name, source, tint):
+ # Persist every input so changes are detected even across restarts.
path = tinted_file(kind, name)
- return path.is_file() and path.stat().st_mtime >= source.stat().st_mtime
+ if not path.is_file():
+ return False
+ try:
+ saved = json.loads(path.with_suffix('.json').read_text('utf-8'))
+ return saved == _signature(source, tint)
+ except (OSError, ValueError):
+ return False
def compose_tint(kind, name, source, tint):
"""Ren'Py's `im.MatrixColor(file, im.matrix.tint(r, g, b))`: a diagonal
color matrix that scales the R/G/B channels independently and leaves
alpha untouched — exactly a per-channel multiply, no channel mixing."""
r, g, b = tint
+ signature = _signature(source, tint)
starttime = time.time()
@@ -47,6 +60,10 @@ def compose_tint(kind, name, source, tint):
tmp = path.with_suffix('.webp.tmp')
img.save(tmp, 'WEBP', quality=CONFIG.setting('images.tint-quality'))
os.replace(tmp, path)
+ metadata = path.with_suffix('.json')
+ temporary_metadata = metadata.with_suffix('.json.tmp')
+ temporary_metadata.write_text(json.dumps(signature), encoding='utf-8')
+ os.replace(temporary_metadata, metadata)
logger.info(f'Tinted image "{kind} {name}" composed in {time.time() - starttime:.4f}s')
diff --git a/app/utils/md/banner.py b/app/utils/md/banner.py
index ccb9f9b..867f631 100644
--- a/app/utils/md/banner.py
+++ b/app/utils/md/banner.py
@@ -35,6 +35,20 @@ def render_banner_open(self, tokens, idx, options, env):
def render_banner_close(self, tokens, idx, options, env):
return ''
+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 82d482b..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
@@ -37,6 +36,7 @@
# without the lead text that follows on the same line. Only the name matters
# here — what a block does with its lead is the parser's own business.
_OPEN_RE = re.compile(r'^:::[ \t]*([A-Za-z][\w-]*)')
+_FENCE_RE = re.compile(r'^(`{3,}|~{3,})(.*)$')
CLOSER = ':::'
@@ -84,18 +84,31 @@ def find_closer(state, startLine: int, endLine: int) -> int | None:
"""
depth = 0
line = startLine + 1
+ fence = None
while line < endLine:
# bMarks + tShift skips the indent, so a closer indented inside a list
# item still reads as a closer — which the corpus relies on.
text = state.src[state.bMarks[line] + state.tShift[line]:state.eMarks[line]].strip()
- if text == CLOSER:
+ marker = _FENCE_RE.match(text)
+ if fence:
+ if (marker and marker[1][0] == fence[0]
+ and len(marker[1]) >= len(fence) and not marker[2].strip()):
+ fence = None
+ elif marker and (marker[1][0] != '`' or '`' not in marker[2]):
+ fence = marker[1]
+ elif text == CLOSER:
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/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 eb007e0..127382d 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,36 +88,12 @@
}
};
- 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.__esdocDocsCleanup?.();
window.__esdocWarperCleanup?.();
window.__esdocToolsCleanup?.();
window.__esdocColorsCleanup?.();
@@ -129,9 +115,10 @@
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);
+ await loadPageScripts(nextDocument, id);
if (id !== navigationId) return;
// Soft navigation replaces the document without the browser's native
@@ -148,6 +135,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 +181,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/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/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/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/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/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
new file mode 100644
index 0000000..b8a97f5
--- /dev/null
+++ b/tests/navigation.test.mjs
@@ -0,0 +1,221 @@
+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); },
+ replaceState(state, title, 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, window,
+ replace(url) { current = new URL(url, current); },
+ 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('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);
+ 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']);
+});
+
+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');
+});
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);
+});
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' },
diff --git a/tests/test_image_cache.py b/tests/test_image_cache.py
new file mode 100644
index 0000000..bb695ce
--- /dev/null
+++ b/tests/test_image_cache.py
@@ -0,0 +1,90 @@
+import asyncio
+import os
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import patch
+
+from PIL import Image
+
+from app.routes.res import _ensure_tinted
+from app.utils.config import CONFIG
+from app.utils.lifespan.sprites_cache import compose_sprite, is_composed, sprites_path
+from app.utils.lifespan.tint_cache import compose_tint, is_tinted
+
+
+class ImageCacheTests(unittest.TestCase):
+ def setUp(self):
+ directory = TemporaryDirectory()
+ self.addCleanup(directory.cleanup)
+ self.root = Path(directory.name)
+ self.game = self.root / 'game'
+ self.game.mkdir()
+ declarations = self.game / 'sprites.rpy'
+ declarations.write_text('', encoding='utf-8')
+ os.utime(declarations, (100, 100))
+ for change in (patch.object(CONFIG, 'res_path', self.game),
+ patch.object(CONFIG, 'cache_path', self.root / 'cache'),
+ patch.object(CONFIG, 'sprite_layers', {'fixture': ['layer.png']}),
+ patch.object(CONFIG, 'resources', {})):
+ change.start()
+ self.addCleanup(change.stop)
+ sprites_path().mkdir(parents=True)
+
+ def image(self, name, color, timestamp=100):
+ path = self.game / name
+ Image.new('RGBA', (2, 2), color).save(path)
+ os.utime(path, (timestamp, timestamp))
+ return path
+
+ def test_recomposes_sprite_after_layer_replacement(self):
+ self.image('layer.png', 'red')
+ output = compose_sprite('fixture')
+ os.utime(output, (200, 200))
+ self.assertTrue(is_composed('fixture'))
+ self.image('layer.png', 'lime', timestamp=300)
+ self.assertFalse(is_composed('fixture'))
+ compose_sprite('fixture')
+ with Image.open(output) as image:
+ red, green, _ = image.convert('RGB').getpixel((0, 0))
+ self.assertGreater(green, 200)
+ self.assertLess(red, 20)
+
+ def test_tint_refreshes_when_factors_change_without_source_edits(self):
+ source = self.image('source.png', 'white')
+ output = compose_tint('bg', 'fixture', source, (1, 0, 0))
+ os.utime(output, (200, 200))
+ CONFIG.resources = {'original': {'bg': [{'name': 'fixture', 'file': 'source.png', 'tint': (0, 1, 0)}]}}
+ asyncio.run(_ensure_tinted('bg', 'fixture'))
+ with Image.open(output) as image:
+ red, green, _ = image.convert('RGB').getpixel((0, 0))
+ self.assertGreater(green, 200)
+ self.assertLess(red, 20)
+
+ def test_tint_refreshes_when_source_identity_changes_to_an_older_file(self):
+ first = self.image('first.png', 'red')
+ self.image('second.png', 'lime', timestamp=50)
+ output = compose_tint('bg', 'fixture', first, (1, 1, 1))
+ os.utime(output, (200, 200))
+ CONFIG.resources = {'original': {'bg': [{'name': 'fixture', 'file': 'second.png', 'tint': (1, 1, 1)}]}}
+ asyncio.run(_ensure_tinted('bg', 'fixture'))
+ with Image.open(output) as image:
+ red, green, _ = image.convert('RGB').getpixel((0, 0))
+ self.assertGreater(green, 200)
+ self.assertLess(red, 20)
+
+ def test_tint_cache_reuses_matching_inputs_and_rebuilds_legacy_metadata(self):
+ source = self.image('source.png', 'white')
+ tint = (1, 0, 0)
+ output = compose_tint('bg', 'fixture', source, tint)
+ self.assertTrue(is_tinted('bg', 'fixture', source, tint))
+ output.with_suffix('.json').unlink()
+ self.assertFalse(is_tinted('bg', 'fixture', source, tint))
+ compose_tint('bg', 'fixture', source, tint)
+ self.assertTrue(is_tinted('bg', 'fixture', source, tint))
+
+ def test_cached_sprite_is_invalid_when_a_layer_is_removed(self):
+ layer = self.image('layer.png', 'red')
+ compose_sprite('fixture')
+ layer.unlink()
+ self.assertFalse(is_composed('fixture'))
diff --git a/tests/test_md_containers.py b/tests/test_md_containers.py
new file mode 100644
index 0000000..2a9bd6d
--- /dev/null
+++ b/tests/test_md_containers.py
@@ -0,0 +1,43 @@
+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'):
+ with self.subTest(fence=fence, marker=marker):
+ source = f':::details Example\n{fence}text\n{marker}\n{fence}\n\nINSIDE\n:::\n\nOUTSIDE\n'
+ html = render(source)[2]
+ self.assertIn(']*>', '', html)))
+ self.assertLess(html.index('INSIDE'), html.index(' '))
+ self.assertGreater(html.index('OUTSIDE'), html.index(''))
+
+ def test_short_fence_does_not_end_long_fenced_example(self):
+ html = render(':::info\n````text\n```\n:::\n````\n\nINSIDE\n:::\n')[2]
+ self.assertLess(html.index('INSIDE'), html.index('', html.index('INSIDE')))
+ self.assertIn('```\n:::\n', html)
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')
diff --git a/tests/test_resource_refresh.py b/tests/test_resource_refresh.py
new file mode 100644
index 0000000..177aa6a
--- /dev/null
+++ b/tests/test_resource_refresh.py
@@ -0,0 +1,43 @@
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import patch
+
+from app.utils.config import CONFIG
+from app.utils.lifespan.refresh import _watchers
+from app.utils.lifespan.resources_cache import parse_resources
+
+
+class OriginalResourceRefreshTests(unittest.TestCase):
+ def test_original_file_changes_reparse_resource_listing(self):
+ with TemporaryDirectory() as directory:
+ assets = Path(directory)
+ game = assets / 'game'
+ image = game / 'images' / 'bg' / 'new.jpg'
+ image.parent.mkdir(parents=True)
+ (game / 'resources.rpy').write_text('', encoding='utf-8')
+ with (patch.object(CONFIG, 'docs_path', assets / 'docs'),
+ patch.object(CONFIG, 'res_path', game),
+ patch.object(CONFIG, 'sprite_layers', {}),
+ patch.object(CONFIG, 'resources', {})):
+ parse_resources()
+ image.write_bytes(b'image')
+ for name, matches, refresh in _watchers():
+ if name == 'resources' and matches(image.resolve()):
+ refresh()
+ files = [item['file'] for item in CONFIG.resources['original']['bg']]
+ self.assertIn('images/bg/new.jpg', files)
+ image.unlink()
+ for name, matches, refresh in _watchers():
+ if name == 'resources' and matches(image.resolve()):
+ refresh()
+ self.assertNotIn('images/bg/new.jpg', [item['file'] for item in CONFIG.resources['original']['bg']])
+
+ def test_audio_and_translation_changes_invalidate_resource_metadata(self):
+ assets = Path(__file__).resolve().parents[1] / 'temp' / 'refresh-fixture'
+ game = assets / 'game'
+ with patch.object(CONFIG, 'docs_path', assets / 'docs'), patch.object(CONFIG, 'res_path', game):
+ matches = next(predicate for name, predicate, _ in _watchers() if name == 'resources')
+ for relative in ('audio/music/new.ogg', 'tl/None/translation.rpy', 'zhenya/images/bg/new.jpg'):
+ with self.subTest(relative=relative):
+ self.assertTrue(matches(game / relative))
diff --git a/tests/test_search_discovery.py b/tests/test_search_discovery.py
index 2cb35b2..6da9915 100644
--- a/tests/test_search_discovery.py
+++ b/tests/test_search_discovery.py
@@ -9,6 +9,26 @@
class SearchDiscoveryTests(unittest.IsolatedAsyncioTestCase):
+ async def test_normalizes_trailing_slash_and_surrounding_whitespace(self):
+ with patch.object(CONFIG, 'site_url', ' https://docs.example/ '):
+ async with AsyncClient(transport=ASGITransport(app=app), base_url='http://localhost') as client:
+ robots = await client.get('/robots.txt')
+ sitemap = await client.get('/sitemap.xml')
+ self.assertIn('Sitemap: https://docs.example/sitemap.xml\n', robots.text)
+ self.assertIn('https://docs.example/docs/', sitemap.text)
+ self.assertNotIn('https://docs.example//', sitemap.text)
+
+ async def test_missing_or_invalid_origin_does_not_publish_broken_urls(self):
+ for origin in (None, '', 'not-a-url', 'https://docs.example/path', 'https://docs.example?query=1'):
+ with self.subTest(origin=origin), patch.object(CONFIG, 'site_url', origin):
+ async with AsyncClient(transport=ASGITransport(app=app, raise_app_exceptions=False), base_url='http://untrusted-host') as client:
+ robots = await client.get('/robots.txt')
+ sitemap = await client.get('/sitemap.xml')
+ self.assertEqual(robots.status_code, 200)
+ self.assertNotIn('Sitemap:', robots.text)
+ self.assertEqual(sitemap.status_code, 404)
+ self.assertNotIn('untrusted-host', robots.text)
+
async def test_robots_points_to_sitemap_on_configured_origin(self):
with patch.object(CONFIG, 'site_url', 'https://docs.example'):
async with AsyncClient(transport=ASGITransport(app=app), base_url='http://localhost') as client:
diff --git a/tests/test_static.py b/tests/test_static.py
new file mode 100644
index 0000000..5b8acd4
--- /dev/null
+++ b/tests/test_static.py
@@ -0,0 +1,23 @@
+import unittest
+
+from httpx import ASGITransport, AsyncClient
+
+from app.app import app
+
+
+class StaticRouteTests(unittest.IsolatedAsyncioTestCase):
+ async def test_rejects_windows_traversal_and_absolute_paths(self):
+ async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client:
+ for folder in ('css', 'js', 'fonts'):
+ for name in ('..%5C..%5CREADME.md', 'D:%5CGITHUB%5Ces-doc%5CREADME.md', '%00'):
+ with self.subTest(folder=folder, name=name):
+ response = await client.get(f'/static/{folder}/{name}')
+ self.assertEqual(response.status_code, 404)
+
+ async def test_serves_normal_assets_and_returns_404_for_missing_fonts(self):
+ async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client:
+ for path in ('css/main.css', 'js/navigation.js', 'fonts/inter-variable-latin.woff2'):
+ response = await client.get('/static/' + path)
+ self.assertEqual(response.status_code, 200, path)
+ response = await client.get('/static/fonts/missing.woff2')
+ self.assertEqual(response.status_code, 404)
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'
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);