from __future__ import annotations import io import os import socket import subprocess import sys import time import unittest import zipfile from pathlib import Path from urllib.request import urlopen try: from playwright.sync_api import sync_playwright except ImportError: sync_playwright = None BASE_DIR = Path(__file__).resolve().parents[1] def free_port() -> int: with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) @unittest.skipUnless(sync_playwright, "install requirements-test.txt and run: playwright install chromium") class BrowserInteractionTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.base_url = os.environ.get("CCRD_BROWSER_BASE_URL", "").rstrip("/") cls.server = None if not cls.base_url: cls.port = free_port() cls.base_url = f"http://127.0.0.1:{cls.port}" cls.server = subprocess.Popen( [ sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(cls.port), "--log-level", "warning", ], cwd=BASE_DIR, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) for _ in range(120): if cls.server.poll() is not None: raise RuntimeError("Uvicorn exited before browser tests started") try: with urlopen(cls.base_url + "/api/sources", timeout=1) as response: if response.status == 200: break except Exception: time.sleep(0.25) else: cls.server.terminate() raise RuntimeError("Uvicorn did not become ready") cls.playwright = sync_playwright().start() cls.browser = cls.playwright.chromium.launch(headless=True, args=["--no-sandbox"]) @classmethod def tearDownClass(cls): cls.browser.close() cls.playwright.stop() if cls.server is not None: cls.server.terminate() try: cls.server.wait(timeout=10) except subprocess.TimeoutExpired: cls.server.kill() def page(self, viewport=None): context = self.browser.new_context( viewport=viewport or {"width": 1440, "height": 900}, accept_downloads=True, ) page = context.new_page() errors = [] page.on("pageerror", lambda error: errors.append(str(error))) page.goto(self.base_url, wait_until="domcontentloaded") page.locator(".result-item").first.wait_for(timeout=30000) return context, page, errors def search_request_pages(self, page): pages = [] def handle_request(request): if request.method != "POST": return if not request.url.endswith("/api/search") and "/api/search/" not in request.url: return try: body = request.post_data_json except Exception: return pages.append(body.get("page")) page.on("request", handle_request) return pages def test_late_initial_payload_cannot_take_over_a_new_search(self): context, page, errors = self.page() try: result = page.evaluate("""async () => { abortSearchPrefetch(); let releaseInitial; loadInitialPayload = () => new Promise(resolve => { releaseInitial = resolve; }); STATE.query = ''; STATE.page = 1; DOM.sortSelect.value = 'relevance'; const oldSearch = doSearch(); STATE.query = 'new-query'; const requests = []; API.search = (body, source, signal) => new Promise(resolve => { requests.push({signal, resolve}); }); const newSearch = doSearch(); await Promise.resolve(); const owner = STATE.searchController; releaseInitial(null); for (let i = 0; i < 10; i++) await Promise.resolve(); const retainedOwner = STATE.searchController === owner; for (const request of requests) request.resolve({results: [], total: 0}); await Promise.all([oldSearch, newSearch]); return {retainedOwner, requests: requests.length, loading: STATE.isLoading}; }""") self.assertEqual(result, {"retainedOwner": True, "requests": 1, "loading": False}) self.assertEqual(errors, []) finally: context.close() def test_initial_json_body_timeout_falls_back_to_search(self): context, page, errors = self.page() try: result = page.evaluate("""async () => { abortSearchPrefetch(); initialPayloadCache.clear(); const originalFetch = window.fetch; const originalTimeout = window.setTimeout; let aborted = false; window.setTimeout = (fn, ms, ...args) => originalTimeout(fn, ms === REQUEST_TIMEOUT_MS ? 30 : ms, ...args); window.fetch = (url, options = {}) => Promise.resolve({ok: true, json: () => new Promise((resolve, reject) => { if (options.signal) options.signal.addEventListener('abort', () => { aborted = true; reject(new DOMException('aborted', 'AbortError')); }, {once: true}); })}); let searched = false; API.search = async () => { searched = true; return {results: [], total: 0}; }; STATE.query = ''; STATE.page = 1; DOM.sortSelect.value = 'relevance'; const completed = await Promise.race([ doSearch().then(() => true), new Promise(resolve => originalTimeout(() => resolve(false), 500)) ]); window.fetch = originalFetch; window.setTimeout = originalTimeout; return {completed, aborted, searched}; }""") self.assertEqual(result, {"completed": True, "aborted": True, "searched": True}) self.assertEqual(errors, []) finally: context.close() def test_stale_search_failure_does_not_change_current_page(self): context, page, errors = self.page() try: result = page.evaluate("""async () => { abortSearchPrefetch(); let failOld; let finishNew; const toasts = []; showToast = message => toasts.push(message); API.search = body => new Promise((resolve, reject) => { if (body.q === 'old-query') failOld = reject; else finishNew = resolve; }); STATE.query = 'old-query'; const oldSearch = doSearch(); await Promise.resolve(); STATE.query = 'new-query'; STATE.page = 2; const newSearch = doSearch(); await Promise.resolve(); failOld(new TypeError('late network failure')); await oldSearch; const currentPage = STATE.page; finishNew({results: [], total: 0}); await newSearch; return {currentPage, toasts}; }""") self.assertEqual(result, {"currentPage": 2, "toasts": []}) self.assertEqual(errors, []) finally: context.close() def test_failed_sidebar_payload_can_be_retried(self): context, page, errors = self.page() try: result = page.evaluate("""async () => { sidebarInitialCache.clear(); let attempts = 0; window.fetch = async () => { if (++attempts === 1) throw new TypeError('connection lost'); return {ok: true, json: async () => ({sources: []})}; }; await loadSidebarInitial(null).catch(() => null); const data = await loadSidebarInitial(null).catch(() => null); return {attempts, recovered: Array.isArray(data && data.sources)}; }""") self.assertEqual(result, {"attempts": 2, "recovered": True}) self.assertEqual(errors, []) finally: context.close() def test_late_root_sidebar_does_not_replace_open_folder(self): for initial in (True, False): with self.subTest(initial=initial): context, page, errors = self.page() try: result = page.evaluate("""async initial => { STATE.source = 'CCRD'; const route = ++routeRenderId; let release; const rootData = {source: 'CCRD', folders: [{name: 'root', path: 'root'}], files: []}; loadSidebarInitial = initial ? () => new Promise(resolve => { release = () => resolve(rootData); }) : async () => null; API.getContents = (source, path) => path ? Promise.resolve({folders: [], files: [{doc_id: 'child', name: '当前目录文件'}]}) : new Promise(resolve => { release = () => resolve(rootData); }); const pending = renderSidebar(route); for (let i = 0; i < 5; i++) await Promise.resolve(); await openFolder('child', route); const html = DOM.sidebarContent.innerHTML; release(); await pending; return {preserved: DOM.sidebarContent.innerHTML === html, path: sidebarCurrentPath}; }""", initial) self.assertEqual(result, {"preserved": True, "path": "child"}) self.assertEqual(errors, []) finally: context.close() def test_old_sidebar_retry_cannot_reopen_previous_folder(self): context, page, errors = self.page() try: result = page.evaluate("""async () => { STATE.source = 'CCRD'; const route = ++routeRenderId; const originalTimeout = window.setTimeout; const retries = []; window.setTimeout = (fn, ms, ...args) => ms === 1200 ? (retries.push(fn), 0) : originalTimeout(fn, ms, ...args); const paths = []; API.getContents = async (source, path) => { paths.push(path); if (path === 'old') throw new TypeError('offline'); return {folders: [], files: [{doc_id: 'new', name: '新目录'}]}; }; await openFolder('old', route); await openFolder('new', route); for (const retry of retries.slice()) retry(); for (let i = 0; i < 5; i++) await Promise.resolve(); window.setTimeout = originalTimeout; return {paths, path: sidebarCurrentPath, visible: DOM.sidebarContent.textContent.includes('新目录')}; }""") self.assertEqual(result, {"paths": ["old", "new"], "path": "new", "visible": True}) self.assertEqual(errors, []) finally: context.close() def test_folder_http_errors_are_not_treated_as_empty_directories(self): context, page, errors = self.page() try: result = page.evaluate("""async () => { folderTreePending.clear(); folderContentsPending.clear(); STATE.folderContentsCache.clear(); window.fetch = async () => ({ok: false, status: 503, json: async () => ({folders: [], files: []})}); const treeRejected = await API.getFolders('CCRD').then(() => false, () => true); const contentsRejected = await API.getContents('CCRD', 'retry').then(() => false, () => true); let requests = 0; window.fetch = async () => { requests++; return {ok: true, json: async () => ({folders: [], files: [{doc_id: 'recovered'}]})}; }; const recovered = await API.getContents('CCRD', 'retry'); return {treeRejected, contentsRejected, requests, recovered: recovered.files[0]?.doc_id}; }""") self.assertEqual(result, {"treeRejected": True, "contentsRejected": True, "requests": 1, "recovered": "recovered"}) self.assertEqual(errors, []) finally: context.close() def test_old_folder_navigation_does_not_restart_search_after_going_home(self): context, page, errors = self.page() try: result = page.evaluate("""async () => { let release; renderSidebar = () => STATE.source ? new Promise(resolve => { release = resolve; }) : Promise.resolve(); const searches = []; const trees = []; renderFolderTree = async () => trees.push(STATE.source); doSearch = () => searches.push(STATE.source); const oldNavigation = navigateToSourceFolder('CCRD', 'old'); navigateHome(); release(); await oldNavigation; return {searches, trees, source: STATE.source}; }""") self.assertEqual(result, {"searches": [None], "trees": [None], "source": None}) self.assertEqual(errors, []) finally: context.close() def test_desktop_controls_search_preview_and_selection(self): context, page, errors = self.page() try: static_controls = ( "hamburger-btn", "search-input", "settings-btn", "theme-btn", "mobile-toggle-btn", "multi-select-toggle", "multi-select-all", "multi-deselect", "multi-batch-download", "multi-zip-download", "empty-random-btn", "close-filters-btn", "fulltext-toggle", "search-paths-toggle", "history-toggle", "exact-search-toggle", "folder-select-all", "folder-deselect-all", "source-filter-cancel", "folder-filter-cancel", "filter-min-size", "filter-min-unit", "filter-max-size", "filter-max-unit", "random-book-btn", "sort-select", ) for control_id in static_controls: self.assertEqual(page.locator(f"#{control_id}").count(), 1, control_id) original_theme = page.locator("body").get_attribute("class") page.locator("#theme-btn").click() self.assertNotEqual(page.locator("body").get_attribute("class"), original_theme) self.assertEqual(page.locator("body.theme-transitioning").count(), 0) self.assertGreater(page.locator("#theme-btn .theme-icon-svg").evaluate("node => node.getAnimations().length"), 0) page.locator("#settings-btn").click() self.assertTrue(page.evaluate("STATE.rightSidebarOpen")) page.locator("#close-filters-btn").click() self.assertFalse(page.evaluate("STATE.rightSidebarOpen")) page.locator("#mobile-toggle-btn").click() self.assertTrue(page.locator("body").evaluate("node => node.classList.contains('mobile')")) page.locator("#mobile-toggle-btn").click() self.assertFalse(page.locator("body").evaluate("node => node.classList.contains('mobile')")) page.locator("#settings-btn").click() page.locator("label:has(#fulltext-toggle)").click() self.assertEqual(page.locator("#search-input").get_attribute("placeholder"), "搜索 TXT 文件或路径...") page.locator("label:has(#exact-search-toggle)").click() page.locator("label:has(#search-paths-toggle)").click() page.locator("label:has(#history-toggle)").click() self.assertIn("fulltext=0", page.url) self.assertIn("exact=0", page.url) self.assertIn("search_paths=0", page.url) self.assertIn("history=0", page.url) page.locator("#close-filters-btn").click() page.locator("#sort-select").select_option("name") page.locator("#search-input").fill("文化革命") page.wait_for_timeout(400) page.locator(".result-item").first.wait_for(timeout=30000) self.assertIn("q=", page.url) page.locator("#multi-select-toggle").check() page.locator(".result-checkbox").first.check() self.assertIn("已选 1 项", page.locator("#multi-selected-count").text_content()) page.locator("#multi-select-all").click() self.assertGreater(page.locator(".result-checkbox:checked").count(), 0) page.locator("#multi-select-all").click() self.assertEqual(page.locator(".result-checkbox:checked").count(), 0) page.locator("#multi-deselect").click() self.assertEqual(page.locator(".result-checkbox:checked").count(), 0) page.locator("[data-action='preview']").first.click() page.locator("#preview-panel").wait_for(state="visible", timeout=30000) self.assertEqual(page.locator("[data-action='close-preview']").count(), 1) page.locator("[data-action='close-preview']").click() page.locator("#preview-panel").wait_for(state="hidden") page.locator("#random-book-btn").click() page.locator("#preview-panel").wait_for(state="visible", timeout=30000) page.keyboard.press("Escape") page.locator("#preview-panel").wait_for(state="hidden") self.assertEqual(errors, []) finally: context.close() def test_section_filter_cancel_buttons_preserve_other_filters(self): context, page, errors = self.page() try: page.locator("#settings-btn").click() page.locator("#filter-min-size").fill("1") page.locator("#filter-source-list input").first.check() page.locator("#source-filter-cancel").wait_for(state="visible") page.locator("#source-filter-cancel").click() self.assertEqual(page.evaluate("STATE.selectedSources"), []) self.assertEqual(page.locator("#filter-min-size").input_value(), "1") page.goto(self.base_url + "/CCRD", wait_until="domcontentloaded") page.locator(".result-item").first.wait_for(timeout=30000) page.locator("#settings-btn").click() page.locator("#folder-select-all").click() page.locator("#folder-filter-cancel").wait_for(state="visible") page.locator("#folder-filter-cancel").click() self.assertEqual(page.evaluate("STATE.folderSelections"), []) self.assertNotIn("folder=", page.url) self.assertEqual(errors, []) finally: context.close() def test_folder_bulk_controls_preserve_selection_and_results(self): context, page, errors = self.page() try: page.emulate_media(reduced_motion="reduce") page.locator("#settings-btn").click() toggle = page.locator("#folder-toggle-all") self.assertEqual(toggle.count(), 1) self.assertEqual(page.locator("#folder-collapse-all, #folder-expand-all").count(), 0) self.assertTrue(toggle.is_disabled()) page.evaluate('''()=>{ STATE.source='CCRD';STATE.folderTreeSource='CCRD'; const node=(path,children=[])=>({path,name:path,children,count:1}); STATE.folderTree=[node('a',[node('a/b',[node('a/b/hit')]),node('a/other')]),node('x')]; STATE.folderNodeMap=buildFolderNodeMap(STATE.folderTree);STATE.folderCollapsed={}; STATE.folderSelections=['a/b/hit'];renderFolderTreeRows();updateFolderTreeControls(); document.querySelector('#filter-folder-section').style.display=''; }''') before = page.evaluate('''()=>({selection:STATE.folderSelections.slice(),total:STATE.total, ids:STATE.results.map(r=>r.doc_id),url:location.href,sequence:STATE.searchSequence})''') self.assertEqual(toggle.inner_text(), "收起") toggle.press("Enter") self.assertEqual(toggle.inner_text(), "展开") self.assertEqual(toggle.get_attribute("aria-expanded"), "false") self.assertEqual(page.locator("#filter-folder-tree .filter-folder-item").count(), 2) self.assertTrue(page.evaluate("Object.values(STATE.folderCollapsed).every(Boolean)")) page.locator("#filter-folder-tree .tree-toggle").first.click() self.assertEqual(toggle.inner_text(), "收起") self.assertEqual(page.locator("#filter-folder-tree .filter-folder-item").count(), 4) self.assertTrue(page.locator("[data-folder-path='a/b'] input").evaluate("n=>n.checked")) toggle.press("Space") self.assertEqual(toggle.inner_text(), "展开") toggle.press("Space") self.assertEqual(toggle.inner_text(), "收起") self.assertEqual(toggle.get_attribute("aria-expanded"), "true") self.assertEqual(page.locator("#filter-folder-tree .filter-folder-item").count(), 5) self.assertTrue(page.locator("input[data-folder-path='a/b/hit']").is_checked()) self.assertEqual(page.evaluate('''()=>({selection:STATE.folderSelections.slice(),total:STATE.total, ids:STATE.results.map(r=>r.doc_id),url:location.href,sequence:STATE.searchSequence})'''), before) self.assertTrue(toggle.evaluate("n=>document.activeElement===n")) # Closing the only root hides its still-open nested branches too. page.locator("#filter-folder-tree .tree-toggle").first.click() self.assertEqual(toggle.inner_text(), "展开") toggle.press("Enter") self.assertEqual(page.locator("#filter-folder-tree .filter-folder-item").count(), 5) page.evaluate("STATE.folderTree=[];updateFolderTreeControls()") self.assertTrue(toggle.is_disabled()) self.assertEqual(errors, []) finally: context.close() def test_short_snippet_does_not_keep_three_line_height(self): context, page, errors = self.page() try: lazy_rendering = page.locator(".result-item").first.evaluate( "node => ({ visibility: getComputedStyle(node).contentVisibility, intrinsicSize: getComputedStyle(node).containIntrinsicSize })" ) self.assertEqual(lazy_rendering["visibility"], "auto") self.assertIn("112px", lazy_rendering["intrinsicSize"]) page.locator(".result-snippet").first.wait_for() page.locator(".result-snippet").first.evaluate("node => node.textContent = '短摘要'") height = page.locator(".result-snippet").first.bounding_box()["height"] self.assertLess(height, 40) self.assertEqual(errors, []) finally: context.close() def test_theme_switch_does_not_animate_large_result_tree(self): context, page, errors = self.page() try: page.locator("#results-list").evaluate( """list => { const template = list.querySelector('.result-item'); while (list.children.length < 500) list.appendChild(template.cloneNode(true)); }""" ) page.locator("#theme-btn").click() result_animations = page.locator("#results-list").evaluate( "list => list.getAnimations({subtree: true}).length" ) icon_animations = page.locator("#theme-btn .theme-icon-svg").evaluate( "node => node.getAnimations().length" ) self.assertEqual(page.locator(".result-item").count(), 500) self.assertEqual(result_animations, 0) self.assertGreater(icon_animations, 0) self.assertEqual(errors, []) finally: context.close() def test_long_snippet_clamps_to_three_lines(self): context, page, errors = self.page() try: page.locator(".result-snippet").first.wait_for() page.locator(".result-snippet").first.evaluate("node => { node.textContent = ['第一行','第二行','第三行','第四行','第五行'].join('\\n'); }") height = page.locator(".result-snippet").first.bounding_box()["height"] self.assertLess(height, 90) self.assertEqual(errors, []) finally: context.close() def test_source_folder_controls_and_mobile_sidebars(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() errors = [] page.on("pageerror", lambda error: errors.append(str(error))) try: page.goto(self.base_url + "/CCRD", wait_until="domcontentloaded") page.locator(".result-item").first.wait_for(timeout=30000) self.assertTrue(page.locator("body").evaluate("node => node.classList.contains('mobile')")) page.locator("#hamburger-btn").click() self.assertTrue(page.evaluate("STATE.leftSidebarOpen")) page.locator("#overlay").click(position={"x": 380, "y": 400}) self.assertFalse(page.evaluate("STATE.leftSidebarOpen")) page.locator("#settings-btn").click() self.assertTrue(page.evaluate("STATE.rightSidebarOpen")) page.locator("#close-filters-btn").click() self.assertFalse(page.evaluate("STATE.rightSidebarOpen")) page.locator("#settings-btn").click() page.locator("#folder-select-all").click() self.assertGreaterEqual(page.locator("#filter-folder-tree input:checked").count(), 1) page.evaluate("""() => { const paths = []; flattenNodes(STATE.folderTree, paths); const leaf = paths.sort((left, right) => right.split('/').length - left.split('/').length)[0]; applyFolderSelections(deselectFolderPath(leaf)); applyFolderSelections(selectFolderPath(leaf)); }""") page.locator("#folder-select-all").click() self.assertEqual(page.evaluate("STATE.folderSelections"), []) page.locator("#folder-select-all").click() page.locator("#folder-deselect-all").click() page.locator("#filter-min-size").fill("1") page.locator("#filter-min-unit").select_option("KB") page.locator("#close-filters-btn").click() page.locator("#clear-filters-btn").click() self.assertEqual(page.locator("#filter-min-size").input_value(), "") page.locator("[data-action='preview']").first.click() page.locator("#preview-panel").wait_for(state="visible", timeout=30000) page.locator("[data-action='toggle-preview-theme']").click() page.locator("[data-action='close-preview']").click() self.assertEqual(errors, []) finally: context.close() def test_mobile_long_folder_uses_remaining_path_line(self): context, page, errors = self.page({"width": 390, "height": 844}) try: layout = page.evaluate("""async () => { STATE.results = [{ Source: "CCRD", SourceName: "CCRD", File: "路径换行测试", Folder: [ "马列之声ebook小组第七批成果集成(2018.8.17)", "马列之声ebook小组第七批成果集成(2018.8.17)part 1a", "2018.8 part 1", ], Size: 1, doc_id: "browser-path-test", snippet: "", snippet_complete: true, }]; STATE.total = 1; renderResults({ animate: false }); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); const path = document.querySelector('.result-path'); const folders = [...path.querySelectorAll('.path-folder')]; const first = document.createRange(); first.selectNodeContents(folders[1]); const source = folders[0].getBoundingClientRect(); const firstRect = first.getClientRects()[0]; return { sourceTop: source.top, firstTop: firstRect.top, firstLeft: firstRect.left, separatorsNested: [...path.querySelectorAll('.path-sep')].every(sep => sep.parentElement.classList.contains('path-folder')), }; }""") self.assertAlmostEqual(layout["firstTop"], layout["sourceTop"], delta=1) self.assertGreater(layout["firstLeft"], 0) self.assertTrue(layout["separatorsNested"]) self.assertEqual(errors, []) finally: context.close() def test_individual_batch_and_zip_downloads_are_browser_valid(self): context, page, errors = self.page() try: with page.expect_download(timeout=30000) as download_info: page.locator(".result-action-btn.primary").first.click() individual = download_info.value self.assertTrue(individual.suggested_filename.endswith(".txt")) self.assertGreater(Path(individual.path()).stat().st_size, 0) page.locator("#multi-select-toggle").check() page.locator(".result-checkbox").first.check() with page.expect_download(timeout=30000) as download_info: page.locator("#multi-batch-download").click() batch = download_info.value self.assertTrue(batch.suggested_filename.endswith(".txt")) self.assertGreater(Path(batch.path()).stat().st_size, 0) checkboxes = page.locator(".result-checkbox") checkboxes.nth(1).check() with page.expect_download(timeout=120000) as download_info: page.locator("#multi-zip-download").click() archive_download = download_info.value archive_bytes = Path(archive_download.path()).read_bytes() self.assertTrue(archive_bytes.startswith(b"PK")) with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive: self.assertEqual(len(archive.namelist()), 2) self.assertIsNone(archive.testzip()) self.assertNotIn(b"PK\x06\x06", archive_bytes) self.assertEqual(errors, []) finally: context.close() def test_navigation_history_source_filters_and_keyboard(self): context, page, errors = self.page() try: page.locator("#hamburger-btn").click() self.assertFalse(page.evaluate("STATE.leftSidebarOpen")) page.locator("#hamburger-btn").click() self.assertTrue(page.evaluate("STATE.leftSidebarOpen")) page.locator("#settings-btn").click() source_inputs = page.locator("#filter-source-list input") self.assertEqual(source_inputs.count(), 2) page.locator("#filter-source-list label").first.click() self.assertEqual(len(page.evaluate("STATE.selectedSources")), 1) page.locator("#close-filters-btn").click() page.locator("#clear-filters-btn").click() self.assertEqual(page.evaluate("STATE.selectedSources"), []) page.locator("#search-input").fill("文化") page.wait_for_timeout(400) page.locator(".result-item").first.wait_for(timeout=30000) page.locator("#search-input").fill("") page.wait_for_timeout(400) page.locator("#search-input").focus() history_item = page.locator("#search-history-dropdown [data-query]").first history_item.wait_for(state="visible", timeout=10000) history_item.click() self.assertEqual(page.locator("#search-input").input_value(), "文化") page.locator("#search-input").press("Escape") self.assertFalse(page.evaluate("STATE.leftSidebarOpen")) page.keyboard.press("/") self.assertTrue(page.locator("#search-input").evaluate("node => document.activeElement === node")) page.goto(self.base_url + "/CCRD", wait_until="domcontentloaded") page.locator(".result-item").first.wait_for(timeout=30000) self.assertTrue(page.locator("#sidebar-expand-btn").is_visible()) page.locator("#sidebar-expand-btn").click() self.assertTrue(page.locator("#left-sidebar").evaluate("node => node.classList.contains('expanded-wide')")) page.locator("#sidebar-expand-btn").click() self.assertFalse(page.locator("#left-sidebar").evaluate("node => node.classList.contains('expanded-wide')")) back_home = page.locator("#back-home") if back_home.count(): back_home.click() self.assertEqual(page.url.rstrip("/"), self.base_url) self.assertEqual(errors, []) finally: context.close() def test_accessibility_keyboard_paths_sidebar_state_and_preview_focus(self): context, page, errors = self.page() try: self.assertEqual(page.locator("#search-input").get_attribute("aria-label"), "搜索 TXT 文件和正文") self.assertEqual(page.locator("#results-list").get_attribute("role"), "list") path = page.locator(".path-folder[role='button']").first self.assertEqual(path.get_attribute("tabindex"), "0") self.assertTrue(path.get_attribute("aria-label")) page.locator("#settings-btn").press("Enter") self.assertEqual(page.locator("#settings-btn").get_attribute("aria-expanded"), "true") self.assertFalse(page.locator("#right-sidebar").evaluate("node => node.inert")) page.keyboard.press("Escape") self.assertEqual(page.locator("#settings-btn").get_attribute("aria-expanded"), "false") self.assertEqual(page.evaluate("document.activeElement.id"), "hamburger-btn") page.locator("[data-action='preview']").first.click() page.locator("#preview-panel").wait_for(state="visible", timeout=30000) self.assertEqual(page.locator("#preview-panel").get_attribute("role"), "dialog") self.assertEqual(page.locator("#preview-panel").get_attribute("aria-modal"), "false") self.assertFalse(page.locator("#header").evaluate("node => node.inert")) self.assertEqual(page.evaluate("document.activeElement.getAttribute('data-action')"), "close-preview") page.keyboard.press("Escape") page.locator("#preview-panel").wait_for(state="hidden") self.assertFalse(page.locator("#header").evaluate("node => node.inert")) self.assertEqual(page.evaluate("document.activeElement.getAttribute('data-action')"), "preview") self.assertEqual(errors, []) finally: context.close() def test_inline_preview_keeps_search_results_and_sidebars_interactive(self): context, page, errors = self.page() try: # Reduced motion keeps hit-testing independent of preview entrance. page.emulate_media(reduced_motion="reduce") page.locator("[data-action='preview']").first.click() panel = page.locator("#preview-panel") panel.wait_for(state="visible", timeout=30000) self.assertEqual(panel.get_attribute("aria-modal"), "false") for selector in ("#header", "#status-bar", "#results-container", "#left-sidebar"): self.assertFalse(page.locator(selector).evaluate("node => node.inert"), selector) self.assertTrue(page.locator("#right-sidebar").evaluate("node => node.inert")) # Tab must be able to leave an inline preview, not wrap to its first button. page.wait_for_function("document.activeElement.dataset.action === 'close-preview'") page.evaluate("previewFocusableElements()[0].focus()") page.keyboard.press("Shift+Tab") self.assertFalse(panel.evaluate("node => node.contains(document.activeElement)")) dark = page.evaluate("STATE.isDark") page.locator("#theme-btn").click(timeout=3000) self.assertEqual(page.evaluate("STATE.isDark"), not dark) page.locator("#settings-btn").click(timeout=3000) self.assertFalse(page.locator("#right-sidebar").evaluate("node => node.inert")) page.locator("label:has(#fulltext-toggle)").click(timeout=3000) self.assertFalse(page.evaluate("STATE.fulltext")) page.locator("#close-filters-btn").click(timeout=3000) self.assertTrue(page.locator("#right-sidebar").evaluate("node => node.inert")) page.locator("#sort-select").select_option("name", timeout=3000) previous = page.evaluate("STATE.previewDocId") # Use the current rendered list after filtering, without retaining stale nodes. page.wait_for_function("STATE.results.length > 1 && !STATE.isLoading") other = page.locator(f"[data-action='preview']:not([data-doc-id='{previous}'])").first next_id = other.get_attribute("data-doc-id") other.click(timeout=3000) page.wait_for_function("id => STATE.previewDocId === id && !previewController", arg=next_id) sequence = page.evaluate("STATE.searchSequence") page.locator("#search-input").fill("文化", timeout=3000) page.wait_for_function("seq => STATE.query === '文化' && STATE.searchSequence > seq + 1 && !STATE.isLoading", arg=sequence) self.assertTrue(panel.is_visible()) self.assertEqual(page.evaluate("STATE.previewDocId"), next_id) # Escape outside the inline panel belongs to the page, not to preview. page.locator("#search-input").press("Escape") self.assertTrue(panel.is_visible()) self.assertTrue(page.locator("#left-sidebar").evaluate("node => node.inert")) page.locator("#hamburger-btn").click(timeout=3000) self.assertFalse(page.locator("#left-sidebar").evaluate("node => node.inert")) panel.locator("[data-action='close-preview']").press("Escape") panel.wait_for(state="hidden") self.assertFalse(page.locator("#header").evaluate("node => node.inert")) self.assertTrue(page.locator("#right-sidebar").evaluate("node => node.inert")) self.assertEqual(errors, []) finally: context.close() def test_desktop_preview_fullscreen_preserves_content_scroll_and_focus(self): context, page, errors = self.page() try: preview_requests = [] def preview_response(route): preview_requests.append(route.request.url) route.fulfill(json={"title": "全屏预览测试", "source": "CCRD", "path": "测试.txt", "text": "正文内容不应因全屏切换而重新加载。\n" * 1000}) page.route("**/api/preview/**", preview_response) page.locator("[data-action='preview']").first.click() panel = page.locator("#preview-panel") panel.wait_for(state="visible") button = panel.locator("[data-preview-fullscreen]") self.assertEqual(button.get_attribute("aria-label"), "全屏预览") self.assertEqual(button.get_attribute("aria-pressed"), "false") self.assertEqual(panel.locator("[data-preview-tts], .preview-tts-controls").count(), 0) self.assertTrue(button.evaluate("node => node.nextElementSibling.matches('[data-action=close-preview]')")) self.assertLess(button.bounding_box()["x"], panel.locator("[data-action='close-preview']").bounding_box()["x"]) self.assertLess(panel.bounding_box()["width"], page.viewport_size["width"]) page.evaluate("window.__fullscreenBody = document.querySelector('.preview-body'); __fullscreenBody.scrollTop = 320") before_scroll = page.locator(".preview-body").evaluate("node => node.scrollTop") button.press("Enter") self.assertEqual(button.get_attribute("aria-label"), "还原预览") self.assertEqual(button.get_attribute("aria-pressed"), "true") self.assertIn("fullscreen=1", page.url) share_url = page.url bounds = panel.bounding_box() for key, expected in {"x": 0, "y": 0, **page.viewport_size}.items(): self.assertAlmostEqual(bounds[key], expected, delta=1) self.assertGreater(page.locator(".preview-body").bounding_box()["height"], 700) self.assertEqual(panel.get_attribute("aria-modal"), "true") self.assertTrue(page.locator("#header").evaluate("node => node.inert")) self.assertTrue(page.locator("#left-sidebar").evaluate("node => node.inert")) self.assertTrue(page.evaluate("__fullscreenBody === document.querySelector('.preview-body')")) self.assertAlmostEqual(page.locator(".preview-body").evaluate("node => node.scrollTop"), before_scroll, delta=1) self.assertFalse(page.locator("body").evaluate("node => node.classList.contains('mobile')")) page.keyboard.press("Tab") self.assertEqual(page.evaluate("document.activeElement.dataset.action"), "close-preview") count = page.evaluate("previewFocusableElements().length") # Reader entry points are intentionally keyboard-accessible after # the original header controls. Walk the complete visible cycle. for _ in range(count): previous = page.evaluate("previewFocusableElements().indexOf(document.activeElement)") page.keyboard.press("Tab") self.assertTrue(page.evaluate("index => document.activeElement === previewFocusableElements()[(index + 1) % previewFocusableElements().length]", previous)) button.click() self.assertEqual(button.get_attribute("aria-pressed"), "false") self.assertEqual(panel.get_attribute("aria-modal"), "false") self.assertFalse(page.locator("#header").evaluate("node => node.inert")) self.assertFalse(page.locator("#left-sidebar").evaluate("node => node.inert")) self.assertTrue(page.locator("#right-sidebar").evaluate("node => node.inert")) self.assertAlmostEqual(page.locator(".preview-body").evaluate("node => node.scrollTop"), before_scroll, delta=1) button.press("Space") page.keyboard.press("Escape") self.assertTrue(panel.is_visible()) self.assertEqual(button.get_attribute("aria-pressed"), "false") self.assertTrue(button.evaluate("node => document.activeElement === node")) self.assertEqual(len(preview_requests), 1) button.press("Enter") panel.locator("[data-action='close-preview']").click() panel.wait_for(state="hidden") self.assertFalse(panel.evaluate("node => node.classList.contains('preview-fullscreen')")) page.locator("[data-action='preview']").first.click() panel.wait_for(state="visible") self.assertEqual(button.get_attribute("aria-pressed"), "false") button.click() page.go_back() panel.wait_for(state="hidden") self.assertFalse(panel.evaluate("node => node.classList.contains('preview-fullscreen')")) self.assertFalse(page.locator("#header").evaluate("node => node.inert")) page.goto("about:blank") page.goto(share_url, wait_until="domcontentloaded") panel.wait_for(state="visible", timeout=30000) self.assertEqual(button.get_attribute("aria-pressed"), "true") self.assertEqual(panel.locator("[data-preview-tts], .preview-tts-controls").count(), 0) page.keyboard.press("Escape") self.assertEqual(button.get_attribute("aria-pressed"), "false") self.assertNotIn("fullscreen=1", page.url) page.keyboard.press("Escape") panel.wait_for(state="hidden") self.assertEqual(errors, []) finally: context.close() def test_preview_fullscreen_is_desktop_only_and_resets_on_mode_change(self): context, page, errors = self.page({"width": 390, "height": 844}) try: page.locator("[data-action='preview']").first.click() panel = page.locator("#preview-panel") panel.wait_for(state="visible", timeout=30000) button = panel.locator("[data-preview-fullscreen]") self.assertFalse(button.is_visible()) self.assertEqual(panel.get_attribute("aria-modal"), "true") self.assertTrue(page.locator("#header").evaluate("node => node.inert")) panel.locator("[data-action='toggle-preview-theme']").focus() count = page.evaluate("previewFocusableElements().length") for _ in range(count): previous = page.evaluate("previewFocusableElements().indexOf(document.activeElement)") page.keyboard.press("Tab") self.assertTrue(page.evaluate("index => document.activeElement === previewFocusableElements()[(index + 1) % previewFocusableElements().length]", previous)) page.evaluate("STATE.isMobile = false; applyMobileMode()") self.assertTrue(button.is_visible()) self.assertEqual(panel.get_attribute("aria-modal"), "false") self.assertFalse(page.locator("#header").evaluate("node => node.inert")) button.click() self.assertEqual(button.get_attribute("aria-pressed"), "true") bounds = panel.bounding_box() self.assertAlmostEqual(bounds["width"], 390, delta=1) self.assertAlmostEqual(bounds["height"], 844, delta=1) page.evaluate("STATE.isMobile = true; applyMobileMode()") self.assertFalse(button.is_visible()) self.assertEqual(panel.get_attribute("aria-modal"), "true") self.assertTrue(page.locator("#header").evaluate("node => node.inert")) self.assertFalse(panel.evaluate("node => node.classList.contains('preview-fullscreen')")) page.evaluate("STATE.isMobile = false; applyMobileMode()") self.assertEqual(button.get_attribute("aria-pressed"), "false") self.assertEqual(panel.get_attribute("aria-modal"), "false") self.assertFalse(page.locator("#header").evaluate("node => node.inert")) self.assertLess(panel.bounding_box()["width"], 390) self.assertEqual(errors, []) finally: context.close() def test_search_prefetch_advances_to_third_page_after_second_page_loads(self): context, page, errors = self.page() try: pages = self.search_request_pages(page) page.locator("#search-input").fill("文化") page.wait_for_timeout(400) page.locator(".result-item").first.wait_for(timeout=30000) page.locator("#results-container").evaluate("el => { el.scrollTop = el.scrollHeight; }") page.wait_for_timeout(1200) page.wait_for_timeout(1200) self.assertGreaterEqual(page.evaluate("STATE.results.length"), 200) self.assertIn(1, pages) self.assertIn(2, pages) self.assertIn(3, pages) self.assertEqual(pages.count(3), 1) self.assertEqual(errors, []) finally: context.close() def test_initial_search_prefetches_second_page_once(self): context = self.browser.new_context(viewport={"width": 1440, "height": 900}) page = context.new_page() errors = [] page.on("pageerror", lambda error: errors.append(str(error))) pages = self.search_request_pages(page) try: page.goto(self.base_url, wait_until="domcontentloaded") page.locator(".result-item").first.wait_for(timeout=30000) page.wait_for_timeout(2500) self.assertEqual(pages.count(2), 1) self.assertEqual(errors, []) finally: context.close() def test_initial_results_skip_entry_animation_but_search_results_animate(self): context, page, errors = self.page() try: self.assertEqual(page.locator(".result-item.result-enter").count(), 0) page.evaluate(""" window.__sawResultEnter = false; new MutationObserver((mutations, observer) => { if (!mutations.some((mutation) => mutation.target instanceof Element && mutation.target.classList.contains("result-enter") )) return; window.__sawResultEnter = true; observer.disconnect(); }).observe(document.querySelector("#results-list"), { subtree: true, attributes: true, attributeFilter: ["class"], }); """) page.locator("#search-input").fill("文化") page.wait_for_function("window.__sawResultEnter", timeout=30000) self.assertEqual(errors, []) finally: context.close() if __name__ == "__main__": unittest.main()