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