Download tests/test_browser.py from vomebook/Search: direct link, hf CLI and curl.
- Browser
- Download file 50.7 kB
-
https://huggingface.co/spaces/vomebook/Search/resolve/main/tests/test_browser.py
- Command line
-
hf download hf://spaces/vomebook/Search/tests/test_browser.py
-
curl -L -o test_browser.py https://huggingface.co/spaces/vomebook/Search/resolve/main/tests/test_browser.py
50.7 kB
| 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]) | |
| class BrowserInteractionTests(unittest.TestCase): | |
| 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"]) | |
| 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() | |