Download tests/test_browser.py from VoiceOfML/Search: direct link, hf CLI and curl.
- Browser
- Download file 69.4 kB
-
https://huggingface.co/spaces/VoiceOfML/Search/resolve/main/tests/test_browser.py
- Command line
-
hf download hf://spaces/VoiceOfML/Search/tests/test_browser.py
-
curl -L -o test_browser.py https://huggingface.co/spaces/VoiceOfML/Search/resolve/main/tests/test_browser.py
69.4 kB
| from __future__ import annotations | |
| import json | |
| import time | |
| import unittest | |
| try: | |
| from .real_server import get_shared_server | |
| except ImportError: | |
| from real_server import get_shared_server | |
| try: | |
| from playwright.sync_api import Error as PlaywrightError | |
| from playwright.sync_api import sync_playwright | |
| except ImportError: | |
| PlaywrightError = Exception | |
| sync_playwright = None | |
| class BrowserInteractionTests(unittest.TestCase): | |
| def setUpClass(cls): | |
| cls.server = get_shared_server() | |
| cls.base_url = cls.server.base_url | |
| cls.playwright = sync_playwright().start() | |
| try: | |
| cls.browser = cls.playwright.chromium.launch(headless=True, args=["--no-sandbox"]) | |
| except PlaywrightError as error: | |
| cls.playwright.stop() | |
| raise unittest.SkipTest(f"Chromium is unavailable: {error}") | |
| def tearDownClass(cls): | |
| cls.browser.close() | |
| cls.playwright.stop() | |
| def open_page(self, viewport=None, search_handler=None): | |
| context = self.browser.new_context(viewport=viewport or {"width": 1440, "height": 900}) | |
| page = context.new_page() | |
| errors = [] | |
| page.on("pageerror", lambda error: errors.append(str(error))) | |
| result = { | |
| "Repo": "VoiceOfML/Test", "File": "手机资料", "Extension": "txt", | |
| "Folder": ["测试目录"], "Size": 1024, "HasTxt": True, | |
| "Link": "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/test.txt", | |
| "Path": "https://huggingface.co/datasets/VoiceOfML/Test/blob/main/test.txt", | |
| } | |
| def route_api(route, request): | |
| path = request.url.split(self.base_url, 1)[-1] | |
| if path.startswith("/api/search"): | |
| body = request.post_data_json or {} | |
| if search_handler: | |
| return search_handler(route, request, body, result) | |
| payload = {"results": [result], "total": 1, "page": body.get("page", 1), "page_size": body.get("page_size", 100)} | |
| elif path.startswith("/api/repos"): | |
| payload = [{"name": "VoiceOfML/Test", "count": 1}] | |
| elif path.startswith("/api/extensions"): | |
| payload = [{"name": "txt", "count": 1}] | |
| elif path.startswith("/api/folders/Test/contents"): | |
| payload = {"folders": [], "files": [], "current_path": ""} | |
| elif path.startswith("/api/folders/Test"): | |
| payload = [] | |
| elif path.startswith("/api/random-txt/status"): | |
| payload = {"available": True, "count": 1} | |
| elif path.startswith("/api/random"): | |
| payload = result | |
| else: | |
| payload = {} | |
| route.fulfill(status=200, content_type="application/json", body=json.dumps(payload, ensure_ascii=False)) | |
| page.route(self.base_url + "/api/**", route_api) | |
| page.route( | |
| "https://vomebook-hitokoto.hf.space/**", | |
| lambda route: route.fulfill(status=200, content_type="application/json", body='{"hitokoto":"test"}'), | |
| ) | |
| page.goto(self.base_url + "/", wait_until="domcontentloaded") | |
| page.locator("#search-input").wait_for(state="visible") | |
| return context, page, errors | |
| def test_multi_term_highlight_never_marks_inside_tags_or_entities(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.locator("#search-input").fill("手机 a") | |
| title = page.locator("#results-list .result-item .result-title").filter(has_text="手机资料").first | |
| title.wait_for(timeout=15000) | |
| self.assertEqual(title.evaluate("n => [...n.querySelectorAll('mark')].map(m => m.textContent)"), ["手机"]) | |
| self.assertNotIn("rk>", title.inner_text()) | |
| self.assertEqual(page.evaluate("highlightText('Tom & Jerry', 'tom amp')"), "<mark>Tom</mark> & Jerry") | |
| self.assertEqual(page.evaluate("highlightText('abcab', 'ab abc')"), "<mark>abc</mark><mark>ab</mark>") | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_path_only_navigation_does_not_strand_pending_filter_render(self): | |
| context, page, errors = self.open_page() | |
| tree = [{"name": "Test", "path": "", "isRoot": True, "count": 2, "hasDirectFiles": False, "hasChildren": True, | |
| "children": [{"name": n, "path": n, "count": 1, "hasDirectFiles": True, "hasChildren": False, | |
| "children": []} for n in ("docs", "notes")]}] | |
| held = [] | |
| def folders(route): | |
| if not held: | |
| held.append(route) | |
| else: | |
| route.fulfill(status=200, content_type="application/json", body=json.dumps(tree)) | |
| try: | |
| page.route(self.base_url + "/api/folders/Test", folders) | |
| page.evaluate("() => { STATE.rightSidebarOpen = true; ROUTER.navigate('repo', 'Test'); }") | |
| deadline = time.time() + 15 | |
| while not held and time.time() < deadline: | |
| page.wait_for_timeout(50) | |
| self.assertTrue(held, "folder tree request never started") | |
| self.assertIn("加载中", page.locator("#filter-folder-tree").inner_text()) | |
| # A sidebar folder click changes only the path while the tree is pending. | |
| page.evaluate("() => ROUTER.navigate('repo', 'Test', 'docs')") | |
| page.wait_for_function("STATE.browserPath === 'docs'") | |
| page.locator('#filter-folder-tree .filter-folder-item[data-path="docs"]').wait_for(timeout=10000) | |
| held[0].fulfill(status=200, content_type="application/json", body=json.dumps(tree)) | |
| page.wait_for_function("!folderTreeRenderQueue.length") | |
| paths = page.locator("#filter-folder-tree .filter-folder-item").evaluate_all("rows => rows.map(r => r.dataset.path)") | |
| self.assertEqual(paths, ["docs", "notes"]) | |
| self.assertIn("txt", page.locator("#filter-ext-list").inner_text()) | |
| # A later cache hit shows the same normalized roots, without the raw repository root. | |
| page.evaluate("() => ROUTER.navigate('global')") | |
| page.wait_for_function("STATE.mode === 'global'") | |
| page.evaluate("() => ROUTER.navigate('repo', 'Test')") | |
| page.wait_for_function("STATE.mode === 'repo' && STATE.folderTree && STATE.folderTree.length") | |
| page.wait_for_function("!folderTreeRenderQueue.length") | |
| paths = page.locator("#filter-folder-tree .filter-folder-item").evaluate_all("rows => rows.map(r => r.dataset.path)") | |
| self.assertEqual(paths, ["docs", "notes"]) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_late_initial_payload_fallback_does_not_restart_a_newer_query(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.locator("#results-list .result-item").first.wait_for(timeout=15000) | |
| result = page.evaluate('''() => new Promise(async done => { | |
| const original = {fetchJsonWithTimeout, doSearch}; | |
| let release, searches = 0; | |
| fetchJsonWithTimeout = () => new Promise(resolve => { release = resolve; }); | |
| doSearch = () => { searches++; }; | |
| if (typeof readInitialSearchPayload === "function") { | |
| original.readInitialSearchPayload = readInitialSearchPayload; | |
| readInitialSearchPayload = () => null; | |
| } | |
| initialPayloadCache.clear(); | |
| STATE.query = ""; STATE.page = 1; | |
| const pending = searchWithInitialFallbackFresh(); | |
| await new Promise(resolve => setTimeout(resolve, 50)); | |
| const held = typeof release === "function"; | |
| STATE.query = "late-payload-query"; | |
| if (held) release(null); | |
| await pending; | |
| Object.assign(window, original); | |
| done({held, searches}); | |
| })''') | |
| self.assertEqual(result, {"held": True, "searches": 0}) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_desktop_search_theme_filters_and_keyboard(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.locator("#search-input").fill("手机") | |
| page.locator("#results-list .result-item").filter(has_text="手机资料").wait_for(timeout=15000) | |
| self.assertIn("q=", page.url) | |
| original_class = page.locator("body").get_attribute("class") | |
| page.locator("#theme-btn").click() | |
| self.assertNotEqual(original_class, page.locator("body").get_attribute("class")) | |
| page.locator("#settings-btn").click() | |
| page.locator("#sort-select").select_option("name") | |
| page.locator("#sort-select").evaluate("node => node.blur()") | |
| page.keyboard.press("Escape") | |
| page.keyboard.press("/") | |
| self.assertTrue(page.locator("#search-input").evaluate("node => document.activeElement === node")) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_search_clear_control_tracks_query_state(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| search = page.locator("#search-input") | |
| self.assertEqual(search.get_attribute("data-has-query"), "false") | |
| self.assertFalse(page.locator("#search-box").evaluate("el => el.classList.contains('has-query')")) | |
| search.fill("手机") | |
| page.wait_for_function("document.getElementById('search-input').dataset.hasQuery === 'true'") | |
| self.assertTrue(page.locator("#search-box").evaluate("el => el.classList.contains('has-query')")) | |
| search.fill("") | |
| page.wait_for_function("document.getElementById('search-input').dataset.hasQuery === 'false'") | |
| self.assertFalse(page.locator("#search-box").evaluate("el => el.classList.contains('has-query')")) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_malformed_api_envelopes_and_folder_errors_are_rejected(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| self.assertEqual(page.evaluate("""() => ({ | |
| nullRecord: isValidSearchResponse({results:[null],total:1,page:1,page_size:100},1,100), | |
| wrongTotal: isValidSearchResponse({results:[],total:1,page:1,page_size:100},1,100), | |
| wrongPageSize: isValidSearchResponse({results:[],total:0,page:1,page_size:50},1,100), | |
| oversized: isValidSearchResponse({results:Array(101).fill({}),total:101,page:1,page_size:100},1,100) | |
| })"""), { | |
| "nullRecord": False, "wrongTotal": False, | |
| "wrongPageSize": False, "oversized": False, | |
| }) | |
| page.route( | |
| self.base_url + "/api/folders/Test/contents*", | |
| lambda route: route.fulfill(status=500, content_type="application/json", body='{"error":"temporary"}'), | |
| ) | |
| result = page.evaluate("""async () => { | |
| try { await API.getContents('Test', ''); return 'resolved'; } | |
| catch (error) { return error.message; } | |
| }""") | |
| self.assertIn("Invalid folder contents response", result) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_section_filter_cancel_buttons_follow_selected_state(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.locator("#settings-btn").click() | |
| self.assertTrue(page.locator("#repo-filter-cancel").is_hidden()) | |
| page.locator("#filter-ext-list input").first.check() | |
| page.locator("#ext-filter-cancel").wait_for(state="visible") | |
| page.locator("#filter-repo-list input").first.check() | |
| page.locator("#repo-filter-cancel").wait_for(state="visible") | |
| page.locator("#repo-filter-cancel").click() | |
| self.assertEqual(page.evaluate("STATE.filterRepos"), []) | |
| self.assertNotEqual(page.evaluate("STATE.filterExtensions"), []) | |
| page.locator("#ext-filter-cancel").click() | |
| self.assertEqual(page.evaluate("STATE.filterExtensions"), []) | |
| page.goto(self.base_url + "/MLMRL-Hub", wait_until="domcontentloaded") | |
| page.locator("#search-input").wait_for(state="visible") | |
| page.locator("#settings-btn").click() | |
| page.evaluate("STATE.filterFolderSubtrees = ['archive']; updateFilterCancelButtons()") | |
| page.locator("#folder-filter-cancel").click() | |
| self.assertEqual(page.evaluate("[STATE.filterFolderSelfs, STATE.filterFolderSubtrees]"), [[], []]) | |
| self.assertNotIn("folder_subtree=", page.url) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_repository_and_extension_filters_refresh_results(self): | |
| search_bodies = [] | |
| def search_handler(route, request, body, result): | |
| search_bodies.append(body) | |
| filtered = dict(result) | |
| if body.get("repos"): | |
| filtered["File"] = "仓库筛选结果" | |
| elif body.get("extensions"): | |
| filtered["File"] = "类型筛选结果" | |
| route.fulfill( | |
| status=200, | |
| content_type="application/json", | |
| body=json.dumps({ | |
| "results": [filtered], | |
| "total": 1, | |
| "page": body.get("page", 1), | |
| "page_size": body.get("page_size", 100), | |
| }, ensure_ascii=False), | |
| ) | |
| context, page, errors = self.open_page(search_handler=search_handler) | |
| try: | |
| page.locator("#settings-btn").click() | |
| extension = page.locator("#filter-ext-list input").first | |
| extension.check() | |
| page.wait_for_function("STATE.results[0] && STATE.results[0].File === '类型筛选结果'", timeout=5000) | |
| self.assertTrue(extension.is_checked()) | |
| self.assertTrue(any(body.get("extensions") == ["txt"] for body in search_bodies)) | |
| page.locator("#ext-filter-cancel").click() | |
| page.wait_for_function("STATE.filterExtensions.length === 0", timeout=5000) | |
| repository = page.locator("#filter-repo-list input").first | |
| repository.check() | |
| page.wait_for_function("STATE.results[0] && STATE.results[0].File === '仓库筛选结果'", timeout=5000) | |
| self.assertTrue(repository.is_checked()) | |
| self.assertTrue(any(body.get("repos") == ["VoiceOfML/Test"] for body in search_bodies)) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_rapid_repository_filter_changes_are_coalesced(self): | |
| search_bodies = [] | |
| def search_handler(route, request, body, result): | |
| search_bodies.append(body) | |
| filtered = dict(result) | |
| if body.get("repos"): | |
| filtered["File"] = "批量仓库筛选结果" | |
| route.fulfill( | |
| status=200, | |
| content_type="application/json", | |
| body=json.dumps({ | |
| "results": [filtered], | |
| "total": 1, | |
| "page": body.get("page", 1), | |
| "page_size": body.get("page_size", 100), | |
| }, ensure_ascii=False), | |
| ) | |
| context, page, errors = self.open_page(search_handler=search_handler) | |
| try: | |
| page.locator("#settings-btn").click() | |
| page.evaluate("""() => { | |
| renderCheckboxList(DOM.filterRepoList, [ | |
| {key: 'VoiceOfML/Test', label: 'Test', count: 1}, | |
| {key: 'VoiceOfML/Other', label: 'Other', count: 1}, | |
| ], [], vals => { | |
| STATE.filterRepos = vals; | |
| STATE.page = 1; | |
| updateFilterCancelButtons(); | |
| scheduleFilterSearch(); | |
| }); | |
| const inputs = DOM.filterRepoList.querySelectorAll('input'); | |
| for (const input of inputs) { | |
| input.checked = true; | |
| input.dispatchEvent(new Event('change', {bubbles: true})); | |
| } | |
| }""") | |
| page.wait_for_function("STATE.results[0] && STATE.results[0].File === '批量仓库筛选结果'", timeout=5000) | |
| filtered_requests = [body for body in search_bodies if body.get("repos")] | |
| self.assertEqual(len(filtered_requests), 1) | |
| self.assertEqual(filtered_requests[0]["repos"], ["VoiceOfML/Test", "VoiceOfML/Other"]) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_folder_filter_checkbox_updates_and_searches(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.goto(self.base_url + "/MLMRL-Hub", wait_until="domcontentloaded") | |
| page.locator("#search-input").wait_for(state="visible") | |
| page.locator("#settings-btn").click() | |
| page.locator("#filter-folder-section").wait_for(state="visible") | |
| page.evaluate("""() => { | |
| STATE.folderTree = [{ | |
| name: "根", path: "", count: 1, isRoot: true, | |
| hasDirectFiles: false, hasChildren: true, | |
| children: [{ | |
| name: "一级/二级", path: "一级/二级", count: 1, | |
| hasDirectFiles: true, hasChildren: false, | |
| children: [] | |
| }] | |
| }]; | |
| STATE.folderTreeCollapsed = {}; | |
| initializeFolderTreeCollapsed(STATE.folderTree); | |
| renderFilterFolderTree(); | |
| }""") | |
| checkbox = page.locator("#filter-folder-tree .filter-folder-item input").last | |
| checkbox.click() | |
| page.wait_for_function("STATE.filterFolderSubtrees.includes('一级/二级')", timeout=5000) | |
| self.assertTrue(checkbox.is_checked(), page.evaluate("""() => ({ | |
| state: [STATE.filterFolderSelfs, STATE.filterFolderSubtrees], | |
| inputs: [...document.querySelectorAll('#filter-folder-tree input')].map(input => [input.value, input.checked, input.indeterminate]) | |
| })""")) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_repository_directory_navigation_keeps_path_filters(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.goto(self.base_url + "/MLMRL-Hub?folder_self=docs&folder_subtree=archive%2F2026", wait_until="domcontentloaded") | |
| page.wait_for_function("() => STATE.mode === 'repo' && STATE.filterFolderSelfs.includes('docs')") | |
| for path in ("docs/nested", "docs", ""): | |
| page.evaluate("path => ROUTER.navigate('repo', STATE.repo, path)", path) | |
| params = page.evaluate("() => Object.fromEntries(new URL(location.href).searchParams)") | |
| self.assertEqual(params.get("path", ""), path) | |
| self.assertEqual(params.get("folder_self"), "docs") | |
| self.assertEqual(params.get("folder_subtree"), "archive/2026") | |
| self.assertEqual(page.evaluate("STATE.filterFolderSelfs"), ["docs"]) | |
| self.assertEqual(page.evaluate("STATE.filterFolderSubtrees"), ["archive/2026"]) | |
| page.evaluate("ROUTER.navigate('repo', 'VOMEBOOK', '')") | |
| self.assertEqual(page.evaluate("STATE.filterFolderSelfs"), []) | |
| self.assertNotIn("folder_self=", page.url) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_directory_tree_preview_stays_usable_during_slow_contents(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.evaluate("ROUTER.navigate('repo', 'Test', '')") | |
| page.wait_for_function("() => STATE.mode === 'repo' && STATE.repo === 'Test'") | |
| page.locator("#filter-ext-list input").first.wait_for(state="attached") | |
| page.evaluate("""() => { | |
| STATE.folderTree = [{name:'根目录', path:'', children:[ | |
| {name:'docs', path:'docs', count:2, children:[ | |
| {name:'nested', path:'docs/nested', count:1, children:[]} | |
| ]} | |
| ]}]; | |
| }""") | |
| pending = [] | |
| page.route(self.base_url + "/api/folders/Test/contents?path=docs", lambda route: pending.append(route)) | |
| page.evaluate("ROUTER.navigate('repo', 'Test', 'docs')") | |
| page.wait_for_function("""() => STATE.browserPath === 'docs' && | |
| !!document.querySelector('.browser-item[data-type="folder"][data-path="docs/nested"]')""") | |
| self.assertEqual(page.locator(".browser-list .sidebar-loading").text_content(), "文件加载中...") | |
| self.assertTrue(pending) | |
| pending[0].fulfill(status=200, content_type="application/json", body=json.dumps({ | |
| "folders": [{"name": "nested", "path": "docs/nested", "count": 1}], | |
| "files": [{"name": "readme.txt", "ext": "txt", "size": 100, "hasTxt": False}] | |
| })) | |
| page.locator('.browser-item[data-type="file"] .browser-name').wait_for() | |
| self.assertEqual(page.locator('.browser-item[data-type="file"] .browser-name').text_content(), "readme.txt") | |
| warming = [] | |
| page.route(self.base_url + "/api/folders/Test/contents?path=docs%2Fnested", | |
| lambda route: warming.append(route)) | |
| page.wait_for_function("() => API._browserPending.size === 0") | |
| page.locator('.browser-item[data-type="folder"][data-path="docs/nested"]').dispatch_event("pointerover") | |
| page.wait_for_function("() => API._browserPending.has('Test|docs/nested')", timeout=3000) | |
| self.assertEqual(len(warming), 1) | |
| page.evaluate("ROUTER.navigate('repo', 'Test', 'docs/nested')") | |
| self.assertEqual(len(warming), 1) | |
| warming[0].fulfill(status=200, content_type="application/json", body=json.dumps({ | |
| "folders": [], "files": [{"name": "nested.txt", "ext": "txt", "size": 1}] | |
| })) | |
| page.locator('.browser-item[data-type="file"] .browser-name').wait_for() | |
| self.assertEqual(page.locator('.browser-item[data-type="file"] .browser-name').text_content(), "nested.txt") | |
| page.evaluate("API._browserCache.delete('Test|docs')") | |
| page.route(self.base_url + "/api/folders/Test/contents?path=docs", | |
| lambda route: route.fulfill(status=500, body='{}')) | |
| page.evaluate("ROUTER.navigate('repo', 'Test', 'docs')") | |
| page.locator('.browser-list .sidebar-loading').filter(has_text="正在重试").wait_for(timeout=5000) | |
| page.locator('.browser-item[data-type="folder"][data-path="docs/nested"]').click() | |
| page.locator('.browser-item[data-type="file"] .browser-name').filter(has_text="nested.txt").wait_for() | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_large_directory_navigation_keeps_search_page_and_filter_tree(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.goto(self.base_url + "/MLMRL-Hub", wait_until="domcontentloaded") | |
| page.wait_for_function("() => STATE.mode === 'repo'") | |
| page.locator("#filter-ext-list input").first.wait_for(state="attached") | |
| page.evaluate("""() => { | |
| STATE.folderTree = Array.from({length: 600}, (_, i) => ({ | |
| name: `folder-${i}`, path: `folder-${i}`, count: 1, | |
| hasDirectFiles: true, hasChildren: false, children: [] | |
| })); | |
| renderFilterFolderTree(); | |
| window.__originalFilterRow = DOM.filterFolderTree.firstElementChild; | |
| window.__filterRuns = 0; | |
| window.__searchRuns = 0; | |
| const render = renderFilters, search = searchWithInitialFallback; | |
| renderFilters = (...args) => { window.__filterRuns++; return render(...args); }; | |
| searchWithInitialFallback = (...args) => { window.__searchRuns++; return search(...args); }; | |
| STATE.page = 3; | |
| ROUTER.navigate('repo', STATE.repo, 'folder-300'); | |
| }""") | |
| self.assertTrue(page.evaluate("window.__originalFilterRow.isConnected")) | |
| self.assertEqual(page.evaluate("[STATE.page, window.__searchRuns, window.__filterRuns]"), [3, 0, 0]) | |
| page.evaluate("""() => { | |
| const url = new URL(location.href); | |
| url.searchParams.set('q', 'different query'); | |
| history.pushState(null, '', url.href); | |
| ROUTER.apply(); | |
| }""") | |
| self.assertFalse(page.evaluate("window.__originalFilterRow.isConnected")) | |
| self.assertGreater(page.evaluate("window.__searchRuns"), 0) | |
| self.assertGreater(page.evaluate("window.__filterRuns"), 0) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_deselected_self_files_button_has_neutral_hover_border(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.goto(self.base_url + "/MLMRL-Hub", wait_until="domcontentloaded") | |
| page.locator("#filter-folder-section").wait_for(state="visible") | |
| page.locator("#settings-btn").click() | |
| page.locator("#right-sidebar.open").wait_for(state="visible") | |
| page.evaluate("""() => { | |
| STATE.folderTree = [{name:'根', path:'', count:2, isRoot:true, | |
| showSelfToggle:true, hasDirectFiles:true, hasChildren:true, | |
| children:[{name:'docs', path:'docs', count:1, hasDirectFiles:true, hasChildren:false, children:[]}]}]; | |
| STATE.folderTreeCollapsed = {}; | |
| renderFilterFolderTree(); | |
| }""") | |
| button = page.locator("#filter-folder-tree .folder-self-toggle") | |
| button.click() | |
| self.assertEqual(button.get_attribute("aria-pressed"), "true") | |
| page.wait_for_timeout(220) | |
| selected_border = button.evaluate("node => getComputedStyle(node).borderTopColor") | |
| button.click() | |
| self.assertEqual(button.get_attribute("aria-pressed"), "false") | |
| page.wait_for_function("selected => getComputedStyle(document.querySelector('#filter-folder-tree .folder-self-toggle')).borderTopColor !== selected", arg=selected_border) | |
| self.assertFalse(button.evaluate("node => node.classList.contains('active')")) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_desktop_result_order_and_action_heights(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.locator("#search-input").fill("手机") | |
| row = page.locator("#results-list .result-item").filter(has_text="手机资料") | |
| row.wait_for(timeout=15000) | |
| layout = row.evaluate("""node => { | |
| const rect = selector => node.querySelector(selector).getBoundingClientRect(); | |
| const title = rect('.result-title'); | |
| const actions = rect('.result-actions'); | |
| const path = rect('.result-path'); | |
| const meta = rect('.result-meta'); | |
| const buttons = [...node.querySelectorAll('.result-action-btn')].map(item => item.getBoundingClientRect().height); | |
| return { | |
| titleTop: title.top, | |
| actionsTop: actions.top, | |
| actionsBottom: actions.bottom, | |
| pathTop: path.top, | |
| pathBottom: path.bottom, | |
| pathRight: path.right, | |
| actionsLeft: actions.left, | |
| metaTop: meta.top, | |
| buttons, | |
| }; | |
| }""") | |
| self.assertAlmostEqual(layout["titleTop"], layout["actionsTop"], delta=1) | |
| self.assertGreaterEqual(layout["pathTop"], layout["actionsBottom"] - 1) | |
| self.assertGreaterEqual(layout["metaTop"], layout["pathBottom"]) | |
| self.assertGreater(layout["pathRight"], layout["actionsLeft"]) | |
| self.assertEqual(len(layout["buttons"]), 4) | |
| self.assertTrue(all(abs(height - 24) < 0.1 for height in layout["buttons"])) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_mobile_result_actions_use_compact_column(self): | |
| context, page, errors = self.open_page({"width": 390, "height": 844}) | |
| try: | |
| page.locator("#search-input").fill("手机") | |
| row = page.locator("#results-list .result-item").filter(has_text="手机资料") | |
| row.wait_for(timeout=15000) | |
| layout = row.evaluate("""node => { | |
| const actions = node.querySelector('.result-actions').getBoundingClientRect(); | |
| const buttons = [...node.querySelectorAll('.result-action-btn')].map(item => { | |
| const rect = item.getBoundingClientRect(); | |
| return {height: rect.height, top: rect.top}; | |
| }); | |
| return {height: actions.height, buttons}; | |
| }""") | |
| self.assertEqual(len(layout["buttons"]), 4) | |
| self.assertTrue(all(abs(button["height"] - 20) < 0.1 for button in layout["buttons"])) | |
| self.assertLessEqual(layout["height"], 83) | |
| self.assertEqual(len({round(button["top"], 1) for button in layout["buttons"]}), 4) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_mobile_long_folder_uses_remaining_path_line(self): | |
| context, page, errors = self.open_page({"width": 390, "height": 844}) | |
| try: | |
| layout = page.evaluate("""async () => { | |
| STATE.results = [{ | |
| Repo: "VoiceOfML/VOMEBOOK", | |
| File: "路径换行测试", | |
| Extension: "pdf", | |
| Folder: [ | |
| "马列之声ebook小组第七批成果集成(2018.8.17)", | |
| "马列之声ebook小组第七批成果集成(2018.8.17)part 1a", | |
| "2018.8 part 1", | |
| ], | |
| Size: 1, | |
| }]; | |
| STATE.total = 1; | |
| STATE.hasMore = false; | |
| resetVirtualScrollState(); | |
| renderResults(); | |
| 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 repo = folders[0].getBoundingClientRect(); | |
| const firstRect = first.getClientRects()[0]; | |
| return { | |
| repoTop: repo.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["repoTop"], delta=1) | |
| self.assertGreater(layout["firstLeft"], 0) | |
| self.assertTrue(layout["separatorsNested"]) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_mobile_sidebar_and_view_toggle(self): | |
| context, page, errors = self.open_page({"width": 390, "height": 844}) | |
| try: | |
| self.assertTrue(page.locator("body").evaluate("node => node.classList.contains('mobile')")) | |
| was_open = page.locator("#left-sidebar").evaluate("node => node.classList.contains('open')") | |
| page.locator("#hamburger-btn").click() | |
| self.assertNotEqual(was_open, page.locator("#left-sidebar").evaluate("node => node.classList.contains('open')")) | |
| page.locator("#mobile-toggle-btn").click() | |
| self.assertFalse(page.locator("body").evaluate("node => node.classList.contains('mobile')")) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_mobile_sidebars_do_not_add_history_guards(self): | |
| context, page, errors = self.open_page({"width": 390, "height": 844}) | |
| try: | |
| page.goto(self.base_url + "/?sidebar=0", wait_until="domcontentloaded") | |
| page.locator("#search-input").wait_for(state="visible") | |
| self.assertFalse(page.evaluate("history.state?.voiceSidebarGuard || history.state?.voiceSidebarOverlay")) | |
| page.locator("#hamburger-btn").click() | |
| page.wait_for_function("STATE.leftSidebarOpen") | |
| self.assertFalse(page.evaluate("history.state?.voiceSidebarGuard || history.state?.voiceSidebarOverlay")) | |
| page.locator("#settings-btn").click() | |
| page.wait_for_function("STATE.rightSidebarOpen && !STATE.leftSidebarOpen") | |
| self.assertFalse(page.evaluate("history.state?.voiceSidebarGuard || history.state?.voiceSidebarOverlay")) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_mobile_reader_return_does_not_restore_closed_sidebar(self): | |
| context, page, errors = self.open_page({"width": 390, "height": 844}) | |
| try: | |
| page.route("**/api/reader-content**", lambda route: route.fulfill( | |
| content_type="text/plain", body="Reader navigation")) | |
| page.wait_for_function("() => STATE.leftSidebarOpen") | |
| url = "/static/reader.html?url=https%3A%2F%2Fhuggingface.co%2Fdatasets%2FVoiceOfML%2FTest%2Fresolve%2Fmain%2Fback.txt&ext=txt" | |
| self.assertTrue(page.evaluate("url => navigateToReader(url)", url)) | |
| reader = page.frame_locator("iframe.reader-overlay") | |
| reader.locator(".reader-text").wait_for() | |
| page.evaluate("history.back()") | |
| page.locator("iframe.reader-overlay").wait_for(state="detached") | |
| self.assertFalse(page.evaluate("STATE.leftSidebarOpen || STATE.rightSidebarOpen")) | |
| self.assertIn("sidebar=0", page.url) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_sidebar_expand_button_keeps_svg_state(self): | |
| context, page, errors = self.open_page({"width": 1440, "height": 900}) | |
| try: | |
| page.locator("#sidebar-content .repo-list-item").first.wait_for(state="visible") | |
| page.locator("#sidebar-content .repo-list-item").first.click() | |
| page.wait_for_function("STATE.mode === 'repo'") | |
| button = page.locator("#sidebar-expand-btn") | |
| self.assertEqual(button.locator("svg").count(), 1) | |
| self.assertNotIn("→", button.inner_text()) | |
| button.click() | |
| self.assertTrue(page.locator("#left-sidebar").evaluate("node => node.classList.contains('expanded-wide')")) | |
| self.assertTrue(button.evaluate("node => node.classList.contains('expanded')")) | |
| self.assertEqual(button.locator("svg").count(), 1) | |
| button.click() | |
| self.assertFalse(page.locator("#left-sidebar").evaluate("node => node.classList.contains('expanded-wide')")) | |
| self.assertFalse(button.evaluate("node => node.classList.contains('expanded')")) | |
| self.assertEqual(button.locator("svg").count(), 1) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_mobile_sidebar_returns_by_directory_level(self): | |
| context, page, errors = self.open_page({"width": 390, "height": 844}) | |
| try: | |
| heights = page.evaluate("[document.querySelector('.sidebar-header').getBoundingClientRect().height, document.querySelector('.status-bar').getBoundingClientRect().height]") | |
| self.assertEqual(heights[0], heights[1]) | |
| if page.locator("#left-sidebar").evaluate("node => !node.classList.contains('open')"): | |
| page.locator("#hamburger-btn").click() | |
| page.locator("#sidebar-content .repo-list-item").first.wait_for(state="visible") | |
| page.locator("#sidebar-content .repo-list-item").first.click() | |
| page.wait_for_function("STATE.mode === 'repo' && STATE.browserPath === ''") | |
| self.assertTrue(page.locator("#sidebar-back-btn").is_visible()) | |
| self.assertEqual(page.locator(".sidebar-breadcrumb").inner_text(), "根目录") | |
| page.evaluate("ROUTER.navigate('repo', STATE.repo, 'docs')") | |
| page.wait_for_function("STATE.browserPath === 'docs'") | |
| page.evaluate("ROUTER.navigate('repo', STATE.repo, 'docs/part')") | |
| page.wait_for_function("STATE.browserPath === 'docs/part'") | |
| page.evaluate("history.back()") | |
| page.wait_for_function("STATE.browserPath === 'docs'") | |
| self.assertTrue(page.locator("#left-sidebar").evaluate("node => node.classList.contains('open')")) | |
| page.locator("#sidebar-back-btn").click() | |
| page.wait_for_function("STATE.browserPath === ''") | |
| self.assertEqual(page.locator(".sidebar-breadcrumb").inner_text(), "根目录") | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_ime_composition_defers_search_until_composition_end(self): | |
| requests = [] | |
| def record_search(route, request, body, result): | |
| requests.append(body.get("q", "")) | |
| payload = {"results": [result], "total": 1, "page": 1, "page_size": 100} | |
| route.fulfill(status=200, content_type="application/json", body=json.dumps(payload, ensure_ascii=False)) | |
| context, page, errors = self.open_page(search_handler=record_search) | |
| try: | |
| # The visible input precedes asynchronous startup search/prefetch. | |
| # Observe composition only after those initial requests settle. | |
| page.wait_for_function("STATE._loadedPage >= 1 && !STATE.isLoading && pendingSearchPages.size === 0") | |
| requests.clear() | |
| page.locator("#search-input").dispatch_event("compositionstart") | |
| page.locator("#search-input").fill("手") | |
| page.wait_for_timeout(300) | |
| self.assertEqual(requests, []) | |
| page.locator("#search-input").dispatch_event("compositionend") | |
| page.wait_for_function("() => new URL(location.href).searchParams.get('q') === '手'") | |
| self.assertIn("手", requests) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_newer_search_result_wins_when_older_request_is_delayed(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.unroute(self.base_url + "/api/**") | |
| def ordered(route, request): | |
| path = request.url.split(self.base_url, 1)[-1] | |
| if not path.startswith("/api/search"): | |
| return route.fulfill(status=200, content_type="application/json", body="[]") | |
| body = request.post_data_json or {} | |
| query = body.get("q", "") | |
| if query == "old": | |
| time.sleep(0.4) | |
| item = {"Repo": "VoiceOfML/Test", "File": query or "initial", "Extension": "txt", "Folder": [], "Size": 1, "HasTxt": False} | |
| payload = {"results": [item], "total": 1, "page": 1, "page_size": 100} | |
| route.fulfill(status=200, content_type="application/json", body=json.dumps(payload)) | |
| page.route(self.base_url + "/api/**", ordered) | |
| page.locator("#search-input").fill("old") | |
| page.wait_for_timeout(130) | |
| page.locator("#search-input").fill("new") | |
| page.locator("#results-list .result-item").filter(has_text="new").wait_for(timeout=10000) | |
| self.assertEqual(page.locator("#results-list .result-title").first.text_content(), "new.txt") | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_virtual_scroll_settles_and_thumb_drag_stays_covered(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| result = page.evaluate("""async () => { | |
| const repeat = (value, count) => Array(count).fill(value).join(""); | |
| STATE.results = Array.from({length: 800}, (_, index) => ({ | |
| Repo: "VoiceOfML/Test", | |
| File: index % 4 === 1 ? `virtual-${index}-` + repeat("长标题", 45) : `virtual-${index}`, | |
| Extension: "txt", | |
| Folder: index % 4 >= 2 ? Array.from({length: 10}, (_, part) => `很长的目录-${index}-${part}`) : [], | |
| Size: index + 1, | |
| HasTxt: index % 4 === 3, | |
| })); | |
| STATE.total = STATE.results.length; | |
| STATE.hasMore = false; | |
| STATE.isLoading = false; | |
| DOM.resultsContainer.scrollTop = 0; | |
| resetVirtualScrollState(); | |
| renderResultsSkeleton(); | |
| renderResults(); | |
| await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); | |
| const skeletonCount = DOM.resultsList.querySelectorAll(".result-skeleton-item").length; | |
| const samples = []; | |
| const container = DOM.resultsContainer; | |
| for (const ratio of [0.82, 0.17, 0.94, 0.33, 0.7, 0.05, 1]) { | |
| container.scrollTop = ratio * (container.scrollHeight - container.clientHeight); | |
| container.dispatchEvent(new Event("scroll")); | |
| await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); | |
| const rect = container.getBoundingClientRect(); | |
| const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); | |
| const row = hit && hit.closest(".result-item"); | |
| samples.push(row && row.querySelector(".result-title") ? Number(row.dataset.index) : null); | |
| } | |
| container.scrollTop = 0.4 * (container.scrollHeight - container.clientHeight); | |
| container.dispatchEvent(new Event("scroll")); | |
| await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); | |
| const appendRect = container.getBoundingClientRect(); | |
| const preservedRow = document.elementFromPoint( | |
| appendRect.left + appendRect.width / 2, | |
| appendRect.top + appendRect.height / 2, | |
| ).closest(".result-item"); | |
| const preservedIndex = preservedRow.dataset.index; | |
| const heightBeforeAppend = container.scrollHeight; | |
| const oldLength = STATE.results.length; | |
| ensureHeightTree(); | |
| const heightTreeBeforeAppend = VSCROLL.heightTree; | |
| STATE.results = STATE.results.concat(Array.from({length: 100}, (_, index) => ({ | |
| Repo: "VoiceOfML/Test", File: `appended-${index}`, Extension: "txt", Folder: [], Size: oldLength + index, | |
| }))); | |
| refreshVirtualAfterAppend(); | |
| await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); | |
| let naiveHeight = 0; | |
| let prefixMatches = true; | |
| for (let index = 0; index < VSCROLL.heights.length; index++) { | |
| naiveHeight += VSCROLL.heights[index]; | |
| if (Math.abs(fenwickSum(VSCROLL.heightTree, index + 1) - naiveHeight) > 0.01) prefixMatches = false; | |
| } | |
| const measuredRow = DOM.resultsList.querySelector(`.result-item[data-index="${preservedIndex}"]`); | |
| let repeatedReads = 0; | |
| const originalRect = measuredRow.getBoundingClientRect.bind(measuredRow); | |
| measuredRow.getBoundingClientRect = () => { repeatedReads++; return originalRect(); }; | |
| VSCROLL.measuredWindowKey = ""; | |
| measureHeights(VSCROLL.renderStart, VSCROLL.renderEnd); | |
| const readsAtSameWidth = repeatedReads; | |
| const originalWidth = DOM.resultsContainer.style.width; | |
| DOM.resultsContainer.style.width = Math.max(200, container.clientWidth - 1) + "px"; | |
| VSCROLL.measuredWindowKey = ""; | |
| measureHeights(VSCROLL.renderStart, VSCROLL.renderEnd); | |
| DOM.resultsContainer.style.width = originalWidth; | |
| const preservedAfterAppend = preservedRow === DOM.resultsList.querySelector(`.result-item[data-index="${preservedIndex}"]`); | |
| const treeExtendedInPlace = heightTreeBeforeAppend === VSCROLL.heightTree; | |
| const heightAfterAppend = container.scrollHeight; | |
| updateScrollTrack(); | |
| container.scrollTop = 0; | |
| renderVisible(); | |
| const thumbRect = DOM.scrollThumb.getBoundingClientRect(); | |
| const trackRect = DOM.scrollTrack.getBoundingClientRect(); | |
| DOM.scrollThumb.dispatchEvent(new MouseEvent("mousedown", { | |
| bubbles: true, | |
| clientY: thumbRect.top + thumbRect.height / 2, | |
| })); | |
| for (let step = 1; step <= 40; step++) { | |
| document.dispatchEvent(new MouseEvent("mousemove", { | |
| bubbles: true, | |
| clientY: thumbRect.top + thumbRect.height / 2 + (trackRect.height - thumbRect.height) * step / 40, | |
| })); | |
| } | |
| const scrollTopBeforeDragFrame = container.scrollTop; | |
| await new Promise(resolve => requestAnimationFrame(resolve)); | |
| const scrollTopAfterDragFrame = container.scrollTop; | |
| const dragRect = container.getBoundingClientRect(); | |
| const dragHit = document.elementFromPoint( | |
| dragRect.left + dragRect.width / 2, | |
| dragRect.top + dragRect.height / 2, | |
| ); | |
| const dragHitIndex = dragHit && dragHit.closest(".result-item") | |
| ? Number(dragHit.closest(".result-item").dataset.index) | |
| : null; | |
| const dragDomCount = DOM.resultsList.querySelectorAll(".result-item").length; | |
| document.dispatchEvent(new MouseEvent("mouseup", {bubbles: true})); | |
| return { | |
| samples, | |
| skeletonCount, | |
| preservedAfterAppend, | |
| heightBeforeAppend, | |
| heightAfterAppend, | |
| treeExtendedInPlace, | |
| prefixMatches, | |
| readsAtSameWidth, | |
| readsAfterWidthChange: repeatedReads, | |
| scrollTopBeforeDragFrame, | |
| scrollTopAfterDragFrame, | |
| dragHitIndex, | |
| dragDomCount, | |
| draggingAfterMouseUp: VSCROLL.isDraggingThumb, | |
| }; | |
| }""") | |
| self.assertTrue(all(index is not None for index in result["samples"])) | |
| self.assertEqual(result["skeletonCount"], 0) | |
| self.assertTrue(result["preservedAfterAppend"]) | |
| self.assertGreater(result["heightAfterAppend"], result["heightBeforeAppend"]) | |
| self.assertTrue(result["treeExtendedInPlace"]) | |
| self.assertTrue(result["prefixMatches"]) | |
| self.assertEqual(result["readsAtSameWidth"], 0) | |
| self.assertGreater(result["readsAfterWidthChange"], result["readsAtSameWidth"]) | |
| self.assertEqual(result["scrollTopBeforeDragFrame"], 0) | |
| self.assertGreater(result["scrollTopAfterDragFrame"], result["scrollTopBeforeDragFrame"]) | |
| self.assertIsNotNone(result["dragHitIndex"]) | |
| self.assertLess(result["dragDomCount"], 50) | |
| self.assertFalse(result["draggingAfterMouseUp"]) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_url_filter_roundtrip_restores_query_sort_and_size(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.goto(self.base_url + "/?q=phone&sort=name&min_size=1KB&max_size=2MB&search_folders=false&exact=0", wait_until="domcontentloaded") | |
| page.locator("#search-input").wait_for(state="visible") | |
| self.assertEqual(page.locator("#search-input").input_value(), "phone") | |
| self.assertEqual(page.locator("#sort-select").input_value(), "name") | |
| self.assertEqual(page.locator("#filter-min-size").input_value(), "1") | |
| self.assertEqual(page.locator("#filter-min-unit").input_value(), "KB") | |
| self.assertFalse(page.locator("#search-folders-toggle").is_checked()) | |
| self.assertFalse(page.locator("#exact-search-toggle").is_checked()) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_repository_folder_filters_remain_in_url_after_reload(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| url = self.base_url + "/MLMRL-Hub?folder_self=docs&folder_subtree=archive%2F2026" | |
| page.goto(url, wait_until="domcontentloaded") | |
| page.locator("#search-input").wait_for(state="visible") | |
| page.wait_for_function("() => STATE.filterFolderSelfs.includes('docs') && STATE.filterFolderSubtrees.includes('archive/2026')") | |
| self.assertEqual(page.url.count("folder_self=docs"), 1) | |
| self.assertEqual(page.url.count("folder_subtree=archive%2F2026"), 1) | |
| page.reload(wait_until="domcontentloaded") | |
| page.locator("#search-input").wait_for(state="visible") | |
| page.wait_for_function("() => STATE.filterFolderSelfs.includes('docs') && STATE.filterFolderSubtrees.includes('archive/2026')") | |
| self.assertIn("folder_self=docs", page.url) | |
| self.assertIn("folder_subtree=archive%2F2026", page.url) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_random_book_uses_deterministic_api_result(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.locator("#random-book-btn").click() | |
| page.locator("#toast").filter(has_text="已发起下载").wait_for(timeout=5000) | |
| iframe = page.locator('iframe[src^="/api/download?"]') | |
| iframe.wait_for(state="attached") | |
| self.assertIn("file=", iframe.get_attribute("src")) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_reader_path_returns_to_matching_folder_filter(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| reader_url = page.evaluate("""() => getReaderLink({ | |
| Repo: "VoiceOfML/Test", | |
| File: "路径测试", | |
| Extension: "txt", | |
| Folder: ["一级目录", "二级目录"], | |
| HasTxt: true, | |
| Link: "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/reader.txt", | |
| })""") | |
| page.goto(self.base_url + reader_url, wait_until="domcontentloaded") | |
| path = page.locator("#reader-path") | |
| path.wait_for(state="visible", timeout=10000) | |
| self.assertEqual(path.text_content(), "Test/一级目录/二级目录") | |
| # Only the path text is a target, not the whole title column. | |
| geometry = path.evaluate("""n => { const r = n.getBoundingClientRect(), h = n.parentElement.getBoundingClientRect(); | |
| const range = document.createRange(); range.selectNodeContents(n); | |
| return {button: r.width, heading: h.width, text: range.getBoundingClientRect().width}; }""") | |
| self.assertLess(geometry["button"], geometry["heading"] - 40, geometry) | |
| self.assertLessEqual(geometry["button"], geometry["text"] + 2, geometry) | |
| self.assertTrue(page.locator("#status").is_hidden()) | |
| path.click() | |
| page.wait_for_url("**/Test?**", timeout=10000) | |
| target = page.evaluate("() => ({pathname: location.pathname, folders: new URL(location.href).searchParams.getAll('folder_self')})") | |
| self.assertEqual(target["pathname"], "/Test") | |
| self.assertEqual(target["folders"], ["一级目录/二级目录"]) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_cancelled_extension_filter_is_not_restored_by_stale_reader_link(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.evaluate("STATE.filterExtensions = ['pdf']") | |
| stale_reader_url = page.evaluate("""() => getReaderLink({ | |
| Repo: "VoiceOfML/Test", File: "路径测试", Extension: "txt", Folder: ["docs"], HasTxt: true, | |
| Link: "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/path.txt", | |
| })""") | |
| page.evaluate("STATE.filterExtensions = []; saveStoredExtensionFilters()") | |
| folder_url = page.evaluate("url => new URL(syncReaderFolderFilter(url), location.origin).searchParams.get('folder_url') || ''", stale_reader_url) | |
| self.assertNotIn("ext=", folder_url) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_reader_back_closes_overlay_without_reloading_search(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.locator("#search-input").fill("手机") | |
| page.wait_for_function("() => STATE.query === '手机' && new URL(location.href).searchParams.get('q') === '手机'") | |
| marker = page.evaluate("() => { window.__readerReturnMarker = Math.random(); return window.__readerReturnMarker; }") | |
| reader_url = page.evaluate("""() => getReaderLink({ | |
| Repo: "VoiceOfML/Test", File: "返回测试", Extension: "txt", Folder: [], HasTxt: true, | |
| Link: "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/return.txt", | |
| })""") | |
| search_url = page.url | |
| page.locator("#search-input").focus() | |
| page.evaluate("""() => { | |
| const stale = new URL('/static/reader.html?nav=stale', location.origin).href; | |
| sessionStorage.setItem('reader-return:stale', location.href); | |
| sessionStorage.setItem('reader-navigation-current', JSON.stringify({readerUrl: stale, shareUrl: stale})); | |
| }""") | |
| page.evaluate("url => navigateToReader(url)", reader_url) | |
| self.assertEqual(page.evaluate("() => location.pathname"), "/static/reader.html") | |
| self.assertNotIn("return=", page.url) | |
| self.assertNotIn("nav=", page.url) | |
| self.assertIsNone(page.evaluate("() => sessionStorage.getItem('reader-return:stale')")) | |
| self.assertEqual(page.evaluate("() => Object.keys(sessionStorage).filter(key => key.startsWith('reader-return:')).length"), 1) | |
| reader = page.frame_locator("iframe.reader-overlay") | |
| reader.locator("#back").wait_for(state="visible") | |
| reader.locator("html[data-reader-phase]").wait_for(state="attached") | |
| initial_dark = page.evaluate("() => STATE.isDark") | |
| reader.locator("#history").click() | |
| reader.locator("#history-panel.is-open").wait_for(state="visible") | |
| reader.locator("#theme-toggle").click() | |
| page.wait_for_function("initial => STATE.isDark !== initial", arg=initial_dark) | |
| self.assertEqual(reader.locator("html").get_attribute("data-theme"), "light" if initial_dark else "dark") | |
| reader.locator("#theme-toggle").click() | |
| page.wait_for_function("initial => STATE.isDark === initial", arg=initial_dark) | |
| reader.locator("#history-close").click() | |
| page.wait_for_function("() => document.activeElement === document.querySelector('iframe.reader-overlay')") | |
| self.assertGreater(page.locator("body > [inert]").count(), 0) | |
| reader.locator("#back").click() | |
| page.locator("iframe.reader-overlay").wait_for(state="detached") | |
| self.assertEqual(page.url, search_url) | |
| self.assertEqual(page.evaluate("() => window.__readerReturnMarker"), marker) | |
| self.assertTrue(page.locator("#search-input").evaluate("el => el === document.activeElement")) | |
| self.assertEqual(page.locator("body > [inert]").count(), 0) | |
| self.assertEqual(page.evaluate("() => performance.getEntriesByType('navigation')[0].type"), "navigate") | |
| page.go_forward(wait_until="commit") | |
| page.locator("iframe.reader-overlay").wait_for(state="attached") | |
| self.assertEqual(page.evaluate("() => location.pathname"), "/static/reader.html") | |
| page.evaluate("() => history.replaceState(null, '', location.href)") | |
| page.reload(wait_until="domcontentloaded") | |
| page.locator("#back").click() | |
| page.locator("#search-input").wait_for(state="visible") | |
| self.assertEqual(page.url, search_url) | |
| page.wait_for_function("() => STATE.query === '手机'") | |
| self.assertEqual(page.evaluate("() => Object.keys(sessionStorage).filter(key => key.startsWith('reader-return:')).length"), 0) | |
| self.assertIsNone(page.evaluate("() => sessionStorage.getItem('reader-navigation-current')")) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_reader_back_restores_virtual_search_scroll(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| before = page.evaluate("""async () => { | |
| STATE.results = Array.from({length: 240}, (_, index) => ({ | |
| Repo: "VoiceOfML/Test", | |
| File: `返回滚动-${index}`, | |
| Extension: "txt", | |
| Folder: index % 3 === 0 ? ["目录", `分组-${index}`] : [], | |
| Size: index + 1, | |
| HasTxt: true, | |
| })); | |
| STATE.total = STATE.results.length; | |
| STATE.hasMore = false; | |
| STATE.isLoading = false; | |
| resetVirtualScrollState(); | |
| renderResults(); | |
| await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); | |
| DOM.resultsContainer.scrollTop = 520; | |
| DOM.resultsContainer.dispatchEvent(new Event("scroll")); | |
| await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); | |
| return DOM.resultsContainer.scrollTop; | |
| }""") | |
| self.assertGreater(before, 0) | |
| page.evaluate("url => navigateToReader(url)", "/static/reader.html?title=返回滚动") | |
| page.locator("iframe.reader-overlay").wait_for(state="attached") | |
| page.evaluate("history.back()") | |
| page.locator("iframe.reader-overlay").wait_for(state="detached") | |
| page.wait_for_function( | |
| "expected => Math.abs(DOM.resultsContainer.scrollTop - expected) <= 1", | |
| arg=before, | |
| timeout=10000, | |
| ) | |
| self.assertEqual(page.evaluate("DOM.resultsContainer.scrollTop"), before) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_system_back_exits_reader_in_one_step_with_panel_or_search_open(self): | |
| # Like the Back button, one system back leaves the Reader. It must not | |
| # re-push a history entry (Chromium would mark the search page skippable). | |
| context, page, errors = self.open_page() | |
| try: | |
| page.route("**/api/reader-content**", lambda route: route.fulfill( | |
| content_type="text/plain", body="find-me in the book")) | |
| url = "/static/reader.html?url=https%3A%2F%2Fhuggingface.co%2Fdatasets%2FVoiceOfML%2FTest%2Fresolve%2Fmain%2Fback.txt&ext=txt" | |
| for view in ("panel", "full-search"): | |
| before = page.evaluate("() => [location.href, history.length]") | |
| page.evaluate("url => navigateToReader(url)", url) | |
| reader = page.frame_locator("iframe.reader-overlay") | |
| reader.locator(".reader-text").wait_for() | |
| reader.locator("#history").click() | |
| if view == "full-search": | |
| reader.locator("#full-search-toggle").click() | |
| reader.locator("#full-search-input").fill("find-me") | |
| reader.locator(".full-search-result").wait_for() | |
| page.wait_for_function("view => readerUiState === view", arg=view) | |
| page.evaluate("""() => { window.__pushes = 0; const push = history.pushState.bind(history); | |
| history.pushState = (...args) => { window.__pushes++; return push(...args); }; }""") | |
| page.evaluate("history.back()") | |
| page.locator("iframe.reader-overlay").wait_for(state="detached") | |
| self.assertEqual(page.evaluate("window.__pushes"), 0, view) | |
| self.assertEqual(page.evaluate("() => location.href"), before[0], view) | |
| page.evaluate("() => { delete history.pushState; }") | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_search_snapshot_restores_results_and_page_cache(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| restored = page.evaluate("""async () => { | |
| const first = [ | |
| {Repo: "VoiceOfML/Test", File: "快照一", Extension: "txt", Folder: ["目录"], Size: 1}, | |
| {Repo: "VoiceOfML/Test", File: "快照二", Extension: "txt", Folder: [], Size: 2}, | |
| ]; | |
| STATE.query = "快照查询"; | |
| DOM.searchInput.value = STATE.query; | |
| STATE.results = first; | |
| STATE.total = 2; | |
| STATE.page = 2; | |
| STATE._loadedPage = 2; | |
| STATE._pageCache = {1: first.slice(0, 1), 2: first.slice(1)}; | |
| STATE.hasMore = true; | |
| STATE.isLoading = false; | |
| resetVirtualScrollState(); | |
| renderResults(); | |
| await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); | |
| const key = getSearchViewKey(); | |
| saveSearchViewSnapshot(key); | |
| STATE.query = "另一个查询"; | |
| DOM.searchInput.value = STATE.query; | |
| STATE.results = []; | |
| STATE.total = 0; | |
| STATE.page = 1; | |
| STATE._loadedPage = 0; | |
| STATE._pageCache = {}; | |
| renderResults(); | |
| STATE.query = "快照查询"; | |
| DOM.searchInput.value = STATE.query; | |
| const didRestore = restoreSearchViewSnapshot(getSearchViewKey(), false); | |
| return { | |
| didRestore, | |
| files: STATE.results.map(result => result.File), | |
| total: STATE.total, | |
| page: STATE.page, | |
| loadedPage: STATE._loadedPage, | |
| cachedPages: Object.keys(STATE._pageCache).sort(), | |
| }; | |
| }""") | |
| self.assertTrue(restored["didRestore"]) | |
| self.assertEqual(restored["files"], ["快照一", "快照二"]) | |
| self.assertEqual(restored["total"], 2) | |
| self.assertEqual(restored["page"], 2) | |
| self.assertEqual(restored["loadedPage"], 2) | |
| self.assertEqual(restored["cachedPages"], ["1", "2"]) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_reader_history_switch_updates_parent_url_and_state(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.locator("#search-input").fill("历史切书") | |
| first_url = page.evaluate("""() => getReaderLink({ | |
| Repo: "VoiceOfML/Test", File: "第一本", Extension: "txt", Folder: [], HasTxt: true, | |
| Link: "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/first.txt", | |
| })""") | |
| second_url = page.evaluate("""() => new URL(getReaderLink({ | |
| Repo: "VoiceOfML/Test", File: "第二本", Extension: "txt", Folder: [], HasTxt: true, | |
| Link: "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/second.txt", | |
| }), location.origin).href""") | |
| search_url = page.url | |
| page.evaluate("url => navigateToReader(url)", first_url) | |
| page.frame_locator("iframe.reader-overlay").locator("#back").wait_for() | |
| reader_frame = next(frame for frame in page.frames if frame.parent_frame is not None and "/static/reader.html" in frame.url) | |
| page.wait_for_function("() => !!document.querySelector('iframe.reader-overlay')?.contentWindow?.VoiceOfMLReaderStore") | |
| reader_frame.evaluate("""async (readerUrl) => VoiceOfMLReaderStore.put({ | |
| url: 'https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/second.txt', | |
| title: '第二本.txt', extension: 'txt', readerUrl, page: 1, pageCount: 0, lastReadAt: Date.now() | |
| })""", second_url) | |
| # The panel can open by itself after loading; open it idempotently | |
| # instead of toggling it closed again. | |
| reader_frame.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") | |
| if reader_frame.locator("#history").get_attribute("aria-expanded") != "true": | |
| reader_frame.locator("#history").click() | |
| reader_frame.wait_for_function("() => document.querySelector('#history-panel').classList.contains('is-open')") | |
| reader_frame.locator('.reader-panel-tabs button[data-panel="history"]').click() | |
| reader_frame.locator("#history-list .panel-item-main").filter(has_text="第二本.txt").click() | |
| page.wait_for_function("() => new URL(location.href).searchParams.get('title') === '第二本.txt'") | |
| self.assertEqual(page.evaluate("() => new URL(history.state.readerUrl).searchParams.get('title')"), "第二本.txt") | |
| page.frame_locator("iframe.reader-overlay").locator("#title").filter(has_text="第二本.txt").wait_for() | |
| page.frame_locator("iframe.reader-overlay").locator("#back").click() | |
| page.locator("iframe.reader-overlay").wait_for(state="detached") | |
| self.assertEqual(page.url, search_url) | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| def test_indexeddb_restores_last_search_but_explicit_url_wins(self): | |
| context, page, errors = self.open_page() | |
| try: | |
| page.goto(self.base_url + "/?q=持久搜索&ext=pdf&filters=1", wait_until="domcontentloaded") | |
| page.locator("#search-input").wait_for(state="visible") | |
| page.wait_for_function("() => STATE.query === '持久搜索' && STATE.filterExtensions.includes('pdf')") | |
| page.wait_for_function("""async () => { | |
| const db = await openSearchSessionDB(); | |
| const tx = db.transaction('session', 'readonly'); | |
| const request = tx.objectStore('session').get('last-search'); | |
| const saved = await new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = reject; }); | |
| db.close(); | |
| return saved && saved.url.includes(encodeURIComponent('持久搜索')); | |
| }""") | |
| page.close() | |
| page = context.new_page() | |
| page.on("pageerror", lambda error: errors.append(str(error))) | |
| page.goto(self.base_url + "/", wait_until="domcontentloaded") | |
| page.locator("#search-input").wait_for(state="visible") | |
| page.wait_for_function("() => STATE.query === '持久搜索' && STATE.filterExtensions.includes('pdf') && STATE.rightSidebarOpen") | |
| page.goto(self.base_url + "/?q=显式搜索&ext=txt", wait_until="domcontentloaded") | |
| page.locator("#search-input").wait_for(state="visible") | |
| page.wait_for_function("() => STATE.query === '显式搜索' && STATE.filterExtensions.length === 1 && STATE.filterExtensions[0] === 'txt'") | |
| self.assertEqual(errors, []) | |
| finally: | |
| context.close() | |
| if __name__ == "__main__": | |
| unittest.main() | |