Search / tests /test_browser.py
vomebook's picture
fix(reader): narrow path hit area and keep speech across reader/search handoff
5dae2c4
Raw History Blame Contribute Delete
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
@unittest.skipUnless(sync_playwright, "install requirements-test.txt and run: playwright install chromium")
class BrowserInteractionTests(unittest.TestCase):
@classmethod
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}")
@classmethod
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> &amp; 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()