Search / tests /test_reader_navigation_host.py
OpenCode
Stabilize Reader and search regression tests
5492cc8
Raw History Blame Contribute Delete
11.4 kB
"""Search-host navigation: source checks, session handoff and focus ownership."""
import unittest
import subprocess
from pathlib import Path
from tests import test_paging_recovery as paging
class ReaderNavigationHostTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
paging.PagingRecoveryTests.setUpClass()
@classmethod
def tearDownClass(cls):
paging.PagingRecoveryTests.tearDownClass()
def setUp(self):
self.fixture = paging.PagingRecoveryTests()
self.fixture.setUp()
self.page = self.fixture.page
self.page.route('**/static/reader.html?**', lambda route: route.fulfill(
content_type='text/html', body='<body>reader</body>'))
def tearDown(self):
self.assertEqual(self.fixture.errors, [])
self.fixture.tearDown()
def test_bundled_shell_starts_without_separate_dependency_scripts(self):
root = Path(__file__).resolve().parents[1]
shell = subprocess.check_output(['node', '--input-type=module', '-e', '''
import {readFileSync} from 'node:fs';
import {stripBundledScripts} from './scripts/compose_app.mjs';
process.stdout.write(stripBundledScripts(readFileSync('static/index.html','utf8')));
'''], cwd=root)
bundle = subprocess.check_output(['node', 'scripts/compose_app.mjs'], cwd=root)
with self.fixture.browser.new_context(service_workers='block') as context:
page = context.new_page()
page.on('pageerror', lambda error: self.fixture.errors.append(str(error)))
page.add_init_script(paging.FETCH_SCRIPT)
page.route('**/api/**', lambda route: route.fulfill(content_type='application/json', body='[]'))
page.route('https://vomebook-hitokoto.hf.space/**', lambda route: route.fulfill(body='{}'))
page.route('**/static/app.js', lambda route: route.fulfill(content_type='text/javascript', body=bundle))
page.route(self.fixture.origin + '/?q=paging', lambda route: route.fulfill(content_type='text/html', body=shell))
page.goto(self.fixture.origin + '/?q=paging', wait_until='domcontentloaded')
page.wait_for_function('STATE.results.length === 100 && !STATE.isLoading')
self.assertEqual(page.locator('script[src]').count(), 1)
self.assertTrue(page.evaluate('typeof VoiceOfMLSearchSession.createSearchSession === "function"'))
self.assertTrue(page.evaluate('typeof VoiceOfMLDownloadController.createDownloadController === "function"'))
self.assertTrue(page.evaluate('navigateToReader("/static/reader.html?ext=txt")'))
self.assertTrue(page.evaluate('closeReaderOverlay()'))
def test_replace_preserves_return_focus_session_and_background_attributes(self):
result = self.page.evaluate('''() => {
const trigger=document.createElement('button');document.body.append(trigger);trigger.focus();
const existing=document.createElement('div');existing.setAttribute('inert','');
existing.setAttribute('aria-hidden','false');document.body.append(existing);
const readerPath=new URL(document.querySelector('script[src*="reader-navigation.js"]').src).pathname.replace('reader-navigation.js','reader.html');
const target=readerPath+'?ext=txt&url=https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/a.txt';
const returnUrl=location.href;
navigateToReader(target);const first=readerOverlay;
const token=new URL(first.src).searchParams.get('nav');
const hidden=trigger.hasAttribute('inert') && trigger.getAttribute('aria-hidden')==='true';
handleReaderMessage({origin:location.origin,source:first.contentWindow,data:{type:'voice-reader-open',url:target+'&title=next'}});
const next=readerOverlay, saved=JSON.parse(sessionStorage.getItem('reader-navigation-current'));
const state=history.state;
const nextUrl=new URL(next.src);
const inherited=nextUrl.searchParams.get('nav')===token && nextUrl.searchParams.get('return')===returnUrl;
const session=saved.readerUrl===next.src && state.readerUrl===next.src && saved.returnScroll.viewKey;
const share=!new URL(location.href).searchParams.has('nav') && !new URL(location.href).searchParams.has('return');
const removed=!first.isConnected;
history.replaceState(null,'',returnUrl);closeReaderOverlay();
const restored=document.activeElement===trigger && !trigger.hasAttribute('inert') && !trigger.hasAttribute('aria-hidden') &&
existing.hasAttribute('inert') && existing.getAttribute('aria-hidden')==='false';
const closed=!next.isConnected && closeReaderOverlay()===false;
trigger.remove();existing.remove();return {hidden,inherited,session:!!session,share,removed,restored,closed};
}''')
self.assertTrue(all(result.values()), result)
def test_click_replenishes_evicted_reader_metadata(self):
result = self.page.evaluate('''() => {
const button=DOM.resultsList.querySelector('[data-action="read"]');
const id=new URL(button.dataset.readerUrl,location.origin).searchParams.get('id');
getReaderLink(STATE.results[Number(button.closest('.result-item').dataset.index)]);
const expected=JSON.parse(sessionStorage.getItem('reader-source:'+id));
for(let i=0;i<200;i++) cacheReaderMetadata('other-'+i,{url:'other'},true);
const evicted=!sessionStorage.getItem('reader-source:'+id);
let captured;
navigateToReader=()=>{captured=JSON.parse(sessionStorage.getItem('reader-source:'+id));};
button.click();
return {evicted,expected,captured};
}''')
self.assertTrue(result['evicted'])
self.assertIsNotNone(result['expected'])
self.assertEqual(result['captured'], result['expected'])
def test_reader_folder_url_uses_current_filters(self):
result = self.page.evaluate('''() => {
STATE.filterMinSize=2048; STATE.filterMaxSize=8192;
STATE.filterExtensions=['pdf','txt']; STATE.searchFolders=false; STATE.exact=false;
DOM.sortSelect.value='size'; STATE.recordHistory=false;
const url=new URL(getReaderFolderUrl({Repo:'VoiceOfML/Test',Folder:['a.b','书']}));
return {path:url.pathname,params:Object.fromEntries(url.searchParams)};
}''')
self.assertEqual(result['path'], '/Test')
params = result['params']
self.assertEqual(params['sort'], 'size')
self.assertEqual(params['ext'], 'pdf,txt')
self.assertEqual(params['folder_self'], 'a.b/书')
self.assertEqual(params['min_size'], '2KB')
self.assertEqual(params['max_size'], '8KB')
self.assertEqual(params['search_folders'], 'false')
self.assertEqual(params['exact'], '0')
self.assertEqual(params['history'], '0')
self.assertNotIn('q', params)
def test_reader_return_captures_cleared_query_before_search_finishes(self):
result = self.page.evaluate('''() => {
STATE.query = '旧查询'; DOM.searchInput.value = STATE.query; syncStateToURL(true);
const originalSearch = doSearch;
doSearch = () => new Promise(() => {});
try {
submitSearchQuery('', {clearResults: true, updateInput: true});
const current = location.href;
navigateToReader('/static/reader.html?ext=txt');
const saved = JSON.parse(sessionStorage.getItem('reader-navigation-current'));
history.back();
return {current, route: saved.returnScroll.route, reader: readerOverlay !== null};
} finally { doSearch = originalSearch; }
}''')
self.assertNotIn('q=%E6%97%A7%E6%9F%A5%E8%AF%A2', result['current'])
self.assertNotIn('q=%E6%97%A7%E6%9F%A5%E8%AF%A2', result['route'])
self.assertTrue(result['reader'])
def test_reader_folder_return_merges_all_current_search_settings(self):
result = self.page.evaluate('''() => {
STATE.query='手机';
STATE.filterExtensions=['pdf','txt'];
STATE.filterMinSize=2048; STATE.filterMaxSize=8192;
STATE.searchFolders=false; STATE.exact=false; STATE.recordHistory=false;
STATE.useMirrorLinks=false; DOM.sortSelect.value='size';
let target=new URL('/Test?folder_self=docs', location.origin);
target=readerNavigation.mergeSearchParams(target, buildSearchURLParams(), {
keys: READER_RETURN_SEARCH_KEYS
});
return Object.fromEntries(target.searchParams);
}''')
self.assertEqual(result['q'], '手机')
self.assertEqual(result['ext'], 'pdf,txt')
self.assertEqual(result['min_size'], '2KB')
self.assertEqual(result['max_size'], '8KB')
self.assertEqual(result['sort'], 'size')
self.assertEqual(result['search_folders'], 'false')
self.assertEqual(result['exact'], '0')
self.assertEqual(result['history'], '0')
self.assertEqual(result['mirror'], '0')
self.assertEqual(result['folder_self'], 'docs')
def test_untrusted_and_stale_messages_cannot_change_active_reader(self):
result = self.page.evaluate('''() => {
const readerPath=new URL(document.querySelector('script[src*="reader-navigation.js"]').src).pathname.replace('reader-navigation.js','reader.html');
const target=readerPath+'?ext=txt';navigateToReader(target);
const first=readerOverlay, oldSource=first.contentWindow, theme=STATE.isDark;
const message={type:'voice-reader-open',url:target+'&title=bad'};
handleReaderMessage({origin:'https://invalid.example',source:oldSource,data:message});
handleReaderMessage({origin:location.origin,source:window,data:message});
handleReaderMessage({origin:location.origin,source:oldSource,data:{...message,url:'/wrong-path'}});
const untouched=readerOverlay===first;
handleReaderMessage({origin:location.origin,source:oldSource,data:{...message,url:target+'&title=valid'}});
const second=readerOverlay;
handleReaderMessage({origin:location.origin,source:oldSource,data:{type:'voice-reader-theme',theme:theme?'light':'dark'}});
return {untouched,replaced:second!==first,staleIgnored:STATE.isDark===theme};
}''')
self.assertTrue(all(result.values()), result)
def test_unavailable_session_storage_does_not_block_replacement(self):
result = self.page.evaluate('''() => {
const readerPath=new URL(document.querySelector('script[src*="reader-navigation.js"]').src).pathname.replace('reader-navigation.js','reader.html');
const target=readerPath+'?ext=txt';navigateToReader(target);const first=readerOverlay;
const original=Storage.prototype.setItem;
try {
Storage.prototype.setItem=()=>{throw new DOMException('Full','QuotaExceededError');};
handleReaderMessage({origin:location.origin,source:first.contentWindow,data:{type:'voice-reader-open',url:target+'&title=next'}});
return readerOverlay!==first && !first.isConnected && history.state.readerUrl===readerOverlay.src;
} finally {Storage.prototype.setItem=original;}
}''')
self.assertTrue(result)