diff --git a/DRIVER_VERSION b/DRIVER_VERSION index c96cf0e8c..efd5ed20b 100644 --- a/DRIVER_VERSION +++ b/DRIVER_VERSION @@ -1 +1 @@ -b4695e3eb2e6736d1a7b1fd255c4e94f78553660 +f33f0b751be0dd559d86c1ed27ca8baa5f9cb2b2 diff --git a/NODE_VERSION b/NODE_VERSION index ce62013c3..1dda03ddb 100644 --- a/NODE_VERSION +++ b/NODE_VERSION @@ -1 +1 @@ -24.22.1 +26.13.0 diff --git a/tests/assets/serviceworkers/slow-fallback/sw.html b/tests/assets/serviceworkers/slow-fallback/sw.html new file mode 100644 index 000000000..b29d3f2e3 --- /dev/null +++ b/tests/assets/serviceworkers/slow-fallback/sw.html @@ -0,0 +1,4 @@ + diff --git a/tests/assets/serviceworkers/slow-fallback/sw.js b/tests/assets/serviceworkers/slow-fallback/sw.js new file mode 100644 index 000000000..22027322c --- /dev/null +++ b/tests/assets/serviceworkers/slow-fallback/sw.js @@ -0,0 +1,9 @@ +self.addEventListener('install', () => self.skipWaiting()); + +self.addEventListener('activate', event => event.waitUntil(self.clients.claim())); + +self.addEventListener('fetch', () => { + // Hold the request for a while, then let it fall back to the network. + const start = Date.now(); + while (Date.now() - start < 500) {} +}); diff --git a/tests/async/library/chromium/test_connect_over_cdp.py b/tests/async/library/chromium/test_connect_over_cdp.py index e2e136eeb..00fba7b14 100644 --- a/tests/async/library/chromium/test_connect_over_cdp.py +++ b/tests/async/library/chromium/test_connect_over_cdp.py @@ -904,3 +904,50 @@ async def test_CD_035_no_defaults_should_not_affect_new_contexts( await browser.close() finally: await browser_server.close() + + +async def test_CD_053_should_connect_when_another_client_inspects_a_terminated_shared_worker( + browser_type: BrowserType, server: Server, launch_arguments: Dict, port: int +) -> None: + pytest.skip("fixme") + browser = await launch(browser_type, launch_arguments, port) + try: + # Another client inspects the shared worker. + page = await browser.new_page() + inspector = await browser.new_browser_cdp_session() + worker_created: asyncio.Future[str] = asyncio.get_running_loop().create_future() + + def on_target_created(event: Dict[str, Any]) -> None: + if event["targetInfo"]["type"] == "shared_worker": + worker_created.set_result(event["targetInfo"]["targetId"]) + + inspector.on("Target.targetCreated", on_target_created) + await inspector.send("Target.setDiscoverTargets", {"discover": True}) + await page.goto(server.PREFIX + "/shared-worker/shared-worker.html") + assert await page.evaluate("window.sharedWorkerResponsePromise") == "echo:hello" + worker_target_id = await worker_created + await inspector.send( + "Target.attachToTarget", {"targetId": worker_target_id, "flatten": True} + ) + + # The last client of the shared worker goes away, so the worker is terminated. + # The inspector stays attached, so the worker target lingers without a browser context. + await page.close() + # Python: poll the CDP target list instead of expect.poll. + deadline = asyncio.get_running_loop().time() + 5 + while True: + targets = await inspector.send("Target.getTargets") + if not any( + info["targetId"] == worker_target_id for info in targets["targetInfos"] + ): + break + assert asyncio.get_running_loop().time() < deadline + await asyncio.sleep(0.1) + + # A new client auto-attaches to the lingering worker target. + cdp_browser = await browser_type.connect_over_cdp(f"http://127.0.0.1:{port}/") + page2 = await cdp_browser.contexts[0].new_page() + assert await page2.evaluate("1 + 1") == 2 + await cdp_browser.close() + finally: + await browser.close() diff --git a/tests/async/library/chromium/test_oopif.py b/tests/async/library/chromium/test_oopif.py index cfe4e90b2..f29facf9d 100644 --- a/tests/async/library/chromium/test_oopif.py +++ b/tests/async/library/chromium/test_oopif.py @@ -313,11 +313,13 @@ async def test_OO_012_should_take_screenshot( browser: Browser, server: Server, assert_to_be_golden: Callable[[bytes, str], None], + utils: Utils, ) -> None: await page.set_viewport_size({"width": 500, "height": 500}) await page.goto(server.PREFIX + "/dynamic-oopif.html") assert len(page.frames) == 2 await assert_oopif_count(browser, 1) + await utils.rafraf(page.frames[1]) assert_to_be_golden(await page.screenshot(), "screenshot-oopif.png") diff --git a/tests/async/library/test_browsercontext_har.py b/tests/async/library/test_browsercontext_har.py index 39683ad12..e75d618db 100644 --- a/tests/async/library/test_browsercontext_har.py +++ b/tests/async/library/test_browsercontext_har.py @@ -14,6 +14,7 @@ """Port of tests/library/browsercontext-har.spec.ts.""" +import io import json import re import zipfile @@ -316,6 +317,24 @@ async def test_HA_085_should_round_trip_har_zip( ) +async def test_HA_114_should_reject_a_malformed_har_zip( + context_factory: Callable[..., Awaitable[BrowserContext]], tmp_path: Path +) -> None: + # Python: zipfile builds the in-memory archive instead of yazl's output stream. + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as zip_file: + zip_file.writestr("aa/x.har", b"{}") + # yauzl reports '..' in an entry name as an 'error' event while reading entries. + zip_data = output.getvalue().replace(b"aa/x.har", b"../x.har") + har_path = tmp_path / "malformed.har.zip" + har_path.write_bytes(zip_data) + + context = await context_factory() + with pytest.raises(Error) as exc_info: + await context.route_from_har(har_path) + assert "invalid relative path: ../x.har" in exc_info.value.message + + async def test_HA_086_should_produce_extracted_zip( context_factory: Callable[..., Awaitable[BrowserContext]], server: Server, diff --git a/tests/async/library/test_browsercontext_user_agent.py b/tests/async/library/test_browsercontext_user_agent.py index 5da7eef98..a951c2aa2 100644 --- a/tests/async/library/test_browsercontext_user_agent.py +++ b/tests/async/library/test_browsercontext_user_agent.py @@ -19,7 +19,7 @@ import pytest -from playwright.async_api import Browser, BrowserContext, Error, Playwright +from playwright.async_api import Browser, BrowserContext, Error, Playwright, Route from tests.server import Server, TestServerRequest from tests.utils import chromium_version_less_than, must @@ -41,6 +41,25 @@ async def test_UA_001_should_work(browser: Browser, server: Server) -> None: await context.close() +async def test_UA_007_should_preserve_custom_user_agent_in_route_fetch( + context_factory: Callable[..., Awaitable[BrowserContext]], server: Server +) -> None: + context = await context_factory(user_agent="my-custom-user-agent") + page = await context.new_page() + + async def handle(route: Route) -> None: + await route.fulfill(response=await route.fetch()) + + await page.route("**/empty.html", handle) + request, server_request, _ = await asyncio.gather( + page.wait_for_event("request", predicate=lambda r: r.url == server.EMPTY_PAGE), + server.wait_for_request("/empty.html"), + page.goto(server.EMPTY_PAGE), + ) + assert request.headers["user-agent"] == "my-custom-user-agent" + assert server_request.getHeader("user-agent") == "my-custom-user-agent" + + async def test_UA_002_should_work_for_subframes( browser: Browser, server: Server, utils: Utils ) -> None: diff --git a/tests/async/library/test_capabilities.py b/tests/async/library/test_capabilities.py index 917621f1a..3288540d3 100644 --- a/tests/async/library/test_capabilities.py +++ b/tests/async/library/test_capabilities.py @@ -183,27 +183,19 @@ async def test_CA_011_should_not_crash_on_show_directory_picker( await page.goto(server.EMPTY_PAGE) # "User activation is required to show a file picker." - so we click first. await page.locator("body").click() - picker = asyncio.create_task( - page.evaluate( - """async () => { - const dir = await window.showDirectoryPicker(); - return dir.name; - // In headless it throws (aborted), in headed it stalls (Test ended) and waits for the picker to be accepted. + await page.evaluate( + """() => { + window.showDirectoryPicker().catch(e => { window.__pickerError = String(e); }); }""" - ) ) # The dialog will not be accepted, so we just wait for some time to # to give the browser a chance to crash. await page.wait_for_timeout(3_000) - if picker.done(): - error = picker.exception() - assert isinstance(error, Error) - assert re.search( - r"(DOMException|AbortError): .*The user aborted a request", error.message - ) - else: - # In headed mode the picker stalls until the test ends. - picker.add_done_callback(lambda task: task.exception()) + # In headless it throws (aborted), in headed it stalls and waits for the picker to be accepted. + error = await page.evaluate("() => window.__pickerError ?? 'stalled'") + assert re.search( + r"((DOMException|AbortError): .*The user aborted a request|stalled)", error + ) async def test_CA_012_should_not_crash_on_storage_get_directory( diff --git a/tests/async/library/test_multiclient.py b/tests/async/library/test_multiclient.py index c9a63dc21..a944972c0 100644 --- a/tests/async/library/test_multiclient.py +++ b/tests/async/library/test_multiclient.py @@ -563,3 +563,50 @@ async def test_MC_018_should_stop_tracing_upon_disconnect( await disconnect(playwright, page_a) await page_b.context.tracing.start() + + +@pytest.mark.slow +async def test_MC_020_should_run_locator_handlers_after_reconnecting_with_a_pending_callback( + playwright: Playwright, + connect: Callable[..., Awaitable[Browser]], + remote_server: RemoteServer, + server: Server, +) -> None: + browser = await connect(remote_server.ws_endpoint) + page = await browser.new_page() + await page.goto(server.PREFIX + "/input/handle-locator.html") + + called = 0 + + async def handler() -> None: + nonlocal called + called += 1 + await asyncio.Future() + + await page.add_locator_handler( + page.get_by_text("This interstitial covers the button"), handler + ) + await page.evaluate( + """() => { + window.setupAnnoyingInterstitial('none', 1); + }""" + ) + with pytest.raises(Error) as exc_info: + await page.locator("#target").click(timeout=1000) + assert "Timeout 1000ms exceeded" in exc_info.value.message + assert called == 1 + + await disconnect(playwright, page) + + reconnected_browser = await connect(remote_server.ws_endpoint) + reconnected_page = reconnected_browser.contexts[0].pages[0] + + async def reconnected_handler() -> None: + await reconnected_page.locator("#close").click() + + await reconnected_page.add_locator_handler( + reconnected_page.get_by_text("This interstitial covers the button"), + reconnected_handler, + ) + await reconnected_page.locator("#target").click(timeout=3000) + assert await reconnected_page.evaluate("window.clicked") == 1 diff --git a/tests/async/library/test_popup.py b/tests/async/library/test_popup.py index 73a76117b..0196982e6 100644 --- a/tests/async/library/test_popup.py +++ b/tests/async/library/test_popup.py @@ -209,6 +209,29 @@ async def handle(route: Route) -> None: await context.close() +async def test_PO_029_should_load_subresources_of_an_about_blank_popup_when_the_opener_has_routes( + browser: Browser, server: Server, browser_name: str +) -> None: + if browser_name == "chromium": + pytest.skip( + "Subresources of an about:blank popup are paused on the opener target and never continued" + ) + context = await browser.new_context() + page = await context.new_page() + await page.route("**/*", lambda route: route.continue_()) + await page.goto(server.EMPTY_PAGE) + async with page.expect_popup() as popup_info: + await page.evaluate( + """url => window.open('').document.write(``)""", + server.PREFIX + "/pptr.png", + ) + popup = await popup_info.value + await popup.wait_for_function( + "() => document.querySelector('img')?.naturalWidth > 0" + ) + await context.close() + + async def test_PO_023_browser_context_add_init_script_should_apply_to_an_in_process_popup( browser: Browser, server: Server ) -> None: diff --git a/tests/async/library/test_tracing.py b/tests/async/library/test_tracing.py index 53eee4a21..bb6d04562 100644 --- a/tests/async/library/test_tracing.py +++ b/tests/async/library/test_tracing.py @@ -1233,23 +1233,17 @@ async def test_TR_041_should_not_emit_after_w_o_before( page = await context.new_page() await context.tracing.start(name="name1", snapshots=True) - evaluate_promise = await run_in_background( - page, - lambda: page.evaluate( - """() => { - console.log('started'); - return new Promise(f => window.callback = f); - }""" - ), + # The wait spans the chunks: its "before" is in the first chunk, and its "after" must not get into the second one. + console_promise = await run_in_background( + page, lambda: page.wait_for_event("console") ) - async with page.expect_event("console"): - pass + await page.evaluate("() => 1") await context.tracing.stop_chunk(path=tmp_path / "trace1.zip") assert (traces_dir / "name1.trace").exists() is True await context.tracing.start_chunk(name="name2") - await page.evaluate_handle("() => window.callback()") - await evaluate_promise + await page.evaluate("() => console.log('started')") + await console_promise await context.tracing.stop(path=tmp_path / "trace2.zip") assert (traces_dir / "name2.trace").exists() is True @@ -1272,12 +1266,13 @@ def sanitize(e: Dict[str, Any]) -> Optional[Dict[str, Any]]: events = parse_trace_raw(tmp_path / "trace1.zip").events sanitized = [s for s in map(sanitize, events) if s] assert [{k: v for k, v in s.items() if k != "callId"} for s in sanitized] == [ - {"type": "before"}, {"type": "before", "title": 'Wait for event "console"'}, + {"type": "before"}, {"type": "after"}, ] - call1 = sanitized[0]["callId"] + assert sanitized[1]["callId"] > sanitized[0]["callId"] assert sanitized[1]["callId"] == sanitized[2]["callId"] + call1 = sanitized[1]["callId"] events = parse_trace_raw(tmp_path / "trace2.zip").events sanitized = [s for s in map(sanitize, events) if s] diff --git a/tests/async/library/test_webmcp.py b/tests/async/library/test_webmcp.py index 185e25da4..54dd913b9 100644 --- a/tests/async/library/test_webmcp.py +++ b/tests/async/library/test_webmcp.py @@ -15,6 +15,7 @@ """Port of tests/library/webmcp.spec.ts.""" import asyncio +import re from typing import AsyncGenerator, Dict import pytest @@ -201,9 +202,14 @@ async def test_WM_005_should_throw_when_the_tool_throws( with pytest.raises(Error) as exc_info: await page.webmcp.call_tool("throwing") # Chromium does not report the error that the tool threw. - assert ( - "the invocation failed" if browser_name == "chromium" else "boom" - ) in exc_info.value.message + assert re.search( + ( + r"the invocation failed|Tool execution failed" + if browser_name == "chromium" + else r"boom" + ), + exc_info.value.message, + ) async def test_WM_006_should_throw_for_an_unknown_tool( diff --git a/tests/async/page/test_elementhandle_scroll_into_view.py b/tests/async/page/test_elementhandle_scroll_into_view.py index 296d6c4fc..bd4a9ea38 100644 --- a/tests/async/page/test_elementhandle_scroll_into_view.py +++ b/tests/async/page/test_elementhandle_scroll_into_view.py @@ -106,6 +106,40 @@ async def test_EH_096_should_scroll_display_contents_into_view( assert abs(scroll_top - 350) < 1 +async def test_EH_124_should_scroll_nested_display_contents_into_view( + page: Page, browser_name: str, browser_major_version: int +) -> None: + await page.set_content( + """ + +
+
+
Hello
Sibling
+
+
+ """ + ) + div = await page.query_selector("#outer") + assert div + await div.scroll_into_view_if_needed() + # The first box in the flattened subtree of #outer is #target, two levels down, + # not the tall sibling. No whitespace text nodes inside #outer: in Firefox they + # get boxes of their own, which would hide a fallback that only looks one level down. + target_is_visible = await page.eval_on_selector( + "#target", + """target => { + const container = document.getElementById('container').getBoundingClientRect(); + const rect = target.getBoundingClientRect(); + return rect.top >= container.top && rect.bottom <= container.bottom; + }""", + ) + assert target_is_visible is True + + async def test_EH_097_should_work_for_visibility_hidden_element(page: Page) -> None: await page.set_content('
Hello
') div = await page.query_selector("div") diff --git a/tests/async/page/test_page_add_locator_handler.py b/tests/async/page/test_page_add_locator_handler.py index 2611f8b29..f0df87058 100644 --- a/tests/async/page/test_page_add_locator_handler.py +++ b/tests/async/page/test_page_add_locator_handler.py @@ -154,13 +154,19 @@ async def test_LH_005_should_throw_when_handler_times_out( await page.goto(server.PREFIX + "/input/handle-locator.html") called = 0 - stall_future: "asyncio.Future[None]" = asyncio.Future() + release_handler: "asyncio.Future[None]" = asyncio.Future() + handler_finished: "asyncio.Future[Union[Error, None]]" = asyncio.Future() async def handler() -> None: nonlocal called called += 1 # Deliberately timeout. - await stall_future + await release_handler + try: + await page.locator("#close").click(timeout=3000) + handler_finished.set_result(None) + except Error as error: + handler_finished.set_result(error) await page.add_locator_handler( page.get_by_text("This interstitial covers the button"), handler @@ -183,7 +189,8 @@ async def handler() -> None: # Should not enter the same handler while it is still running. assert called == 1 - stall_future.cancel() + release_handler.set_result(None) + assert await handler_finished is None async def test_LH_006_should_work_with_to_be_visible( diff --git a/tests/async/page/test_page_aria_snapshot_ai.py b/tests/async/page/test_page_aria_snapshot_ai.py index 47247372c..17fc0730a 100644 --- a/tests/async/page/test_page_aria_snapshot_ai.py +++ b/tests/async/page/test_page_aria_snapshot_ai.py @@ -190,6 +190,14 @@ async def test_AR_052_should_stitch_all_frame_snapshots( await page.get_by_ref("e1000").normalize() assert "No element matching aria-ref=e1000" in exc_info.value.message + await page.locator("iframe").evaluate_all( + """iframes => { + for (const iframe of iframes) + iframe.textContent = 'Your browser does not support frames'; + }""" + ) + assert await snapshot_for_ai(page) == snapshot + async def test_AR_053_should_re_number_refs_across_navigations_but_not_same_document_navigations( page: Page, server: Server diff --git a/tests/async/page/test_page_drag.py b/tests/async/page/test_page_drag.py index e54f7d4e5..01f311fb1 100644 --- a/tests/async/page/test_page_drag.py +++ b/tests/async/page/test_page_drag.py @@ -656,15 +656,9 @@ async def test_DD_019_should_handle_custom_data_transfer( pytest.skip("fixme") await page.set_content("""""") - async def drag() -> None: - await page.hover('[draggable="true"]') - await page.mouse.down() - await page.mouse.move(100, 100) - await page.mouse.up() - - result, _ = await asyncio.gather( - page.evaluate( - """() => new Promise(resolve => { + await page.evaluate( + """() => { + window.__result = new Promise(resolve => { document.addEventListener('dragstart', event => { event.dataTransfer.setData('custom-type', 'Hello World'); }, false); @@ -683,12 +677,16 @@ async def drag() -> None: data: event.dataTransfer.getData('custom-type'), }); }, false); - })""" - ), - drag(), + }); + }""" ) - assert result == { + await page.hover('[draggable="true"]') + await page.mouse.down() + await page.mouse.move(100, 100) + await page.mouse.up() + + assert await page.evaluate("() => window.__result") == { "types": ["custom-type"], "data": "Hello World", } diff --git a/tests/async/page/test_page_evaluate.py b/tests/async/page/test_page_evaluate.py index 062e4236c..6b91b8b33 100644 --- a/tests/async/page/test_page_evaluate.py +++ b/tests/async/page/test_page_evaluate.py @@ -692,22 +692,15 @@ async def test_EV_062_should_simulate_a_user_gesture(page: Page) -> None: async def test_EV_063_should_throw_a_nice_error_after_a_navigation( page: Page, ) -> None: - async def wait_for_error() -> Error: + async with page.expect_navigation(): with pytest.raises(Error) as exc_info: - await page.evaluate("() => new Promise(f => window['__resolve'] = f)") - return exc_info.value - - async def reload() -> None: - async with page.expect_navigation(): await page.evaluate( - """() => { + """() => new Promise(f => setTimeout(() => { window.location.reload(); - setTimeout(() => window['__resolve'](42), 1000); - }""" + setTimeout(() => f(42), 1000); + }, 0))""" ) - - error, _ = await asyncio.gather(wait_for_error(), reload()) - assert "navigation" in error.message + assert "navigation" in exc_info.value.message async def test_EV_064_should_not_throw_an_error_when_evaluation_does_a_navigation( diff --git a/tests/async/page/test_page_event_network.py b/tests/async/page/test_page_event_network.py index 1d4a86457..dddcc39dd 100644 --- a/tests/async/page/test_page_event_network.py +++ b/tests/async/page/test_page_event_network.py @@ -143,11 +143,13 @@ async def test_NE_089_should_support_redirects(page: Page, server: Server) -> No assert redirected_from.redirected_to == response.request -async def test_NE_090_should_resolve_responses_after_a_navigation( +async def test_NE_090_should_resolve_responses_after_a_cross_process_navigation( page: Page, server: Server, browser_name: str ) -> None: if browser_name == "chromium": - pytest.skip("fixme") + pytest.skip( + "Chromium does not report loadingFailed when the navigation swaps the frame" + ) responses_from_server: List[TestServerRequest] = [] server_got_request = threading.Event() @@ -180,3 +182,44 @@ def end() -> None: reactor.callFromThread(end) # the response should resolve to null, because the page navigated. assert await response_promise is None + + +async def test_NE_143_should_resolve_responses_after_a_same_process_navigation( + page: Page, server: Server, browser_name: str +) -> None: + if browser_name == "chromium": + pytest.skip( + "Chromium does not report loadingFailed when the navigation swaps the frame" + ) + responses_from_server: List[TestServerRequest] = [] + server_got_request = threading.Event() + + def handle(server_request: TestServerRequest) -> None: + responses_from_server.append(server_request) + server_got_request.set() + + server.set_route("/foo", handle) + await page.goto(server.EMPTY_PAGE) + async with page.expect_request(lambda _: True) as request_info: + # start a long running request and wait until it hits the server + await page.evaluate("url => void fetch(url)", server.PREFIX + "/foo") + while not server_got_request.is_set(): + await page.wait_for_timeout(10) + response_from_server = responses_from_server[0] + request = await request_info.value + response_promise = asyncio.create_task(request.response()) + # navigate within the same process, which should cancel the request + await page.goto(server.PREFIX + "/title.html") + + # make sure we aren't stalling this request on the server + def end() -> None: + try: + response_from_server.write(b"done") + response_from_server.finish() + except RuntimeError: + # Python: Node's res.end() also accepts an already dropped request. + pass + + reactor.callFromThread(end) + # the response should resolve to null, because the page navigated. + assert await response_promise is None diff --git a/tests/async/page/test_page_event_pageerror.py b/tests/async/page/test_page_event_pageerror.py index 190f89155..077393ec6 100644 --- a/tests/async/page/test_page_event_pageerror.py +++ b/tests/async/page/test_page_event_pageerror.py @@ -29,7 +29,11 @@ def expect_failure(request: pytest.FixtureRequest, reason: str) -> None: async def test_ER_001_should_fire( - page: Page, server: Server, browser_name: str, request: pytest.FixtureRequest + page: Page, + server: Server, + browser_name: str, + browser_major_version: int, + request: pytest.FixtureRequest, ) -> None: url = server.PREFIX + "/error.html" async with page.expect_event("pageerror") as error_info: @@ -104,7 +108,7 @@ def handle_page(request: TestServerRequest) -> None: await page.goto(server.PREFIX + "/cross-origin-error.html") cross_origin_error = await cross_origin_error_info.value assert cross_origin_error.message == "Fancy error!" - if browser_name == "chromium": + if browser_name == "chromium" and browser_major_version >= 155: assert ( cross_origin_error.stack == f"""Uncaught Error: Fancy error! diff --git a/tests/async/page/test_page_focus.py b/tests/async/page/test_page_focus.py index b7268394f..434a8cf30 100644 --- a/tests/async/page/test_page_focus.py +++ b/tests/async/page/test_page_focus.py @@ -329,3 +329,21 @@ async def test_FO_012_should_tab_from_an_iframe_to_its_parent( ) await page.keyboard.press("Tab") await expect(page.locator("#outer")).to_be_focused() + + +async def test_FO_013_should_move_focus_from_the_parent_into_its_iframe( + page: Page, server: Server +) -> None: + _frame_route(server) + await page.goto(server.EMPTY_PAGE) + await page.set_content( + f'' + ) + await page.locator("#outer").focus() + await expect(page.locator("#outer")).to_be_focused() + # Call native focus() exactly once, locator.focus() would hide the bug by calling it twice. + await page.evaluate( + "() => document.querySelector('iframe').contentDocument.querySelector('input').focus()" + ) + await expect(page.locator("iframe")).to_be_focused() + await expect(page.frame_locator("iframe").locator("#inner")).to_be_focused() diff --git a/tests/async/page/test_page_request_fulfill.py b/tests/async/page/test_page_request_fulfill.py index 1565b1749..94f816c4c 100644 --- a/tests/async/page/test_page_request_fulfill.py +++ b/tests/async/page/test_page_request_fulfill.py @@ -110,16 +110,12 @@ async def test_RO_100_should_not_throw_if_request_was_cancelled_by_the_page( intercept_future: "asyncio.Future[Route]" = asyncio.Future() await page.route("**/data.json", lambda route: intercept_future.set_result(route)) await page.goto(server.EMPTY_PAGE) - evaluate_task = asyncio.create_task( - ignore_error( - page.evaluate( - """url => { - globalThis.controller = new AbortController(); - return fetch(url, { signal: globalThis.controller.signal }); - }""", - server.PREFIX + "/data.json", - ) - ) + await page.evaluate( + """url => { + globalThis.controller = new AbortController(); + fetch(url, { signal: globalThis.controller.signal }).catch(() => {}); + }""", + server.PREFIX + "/data.json", ) route = await intercept_future async with page.expect_event("requestfailed") as failure_info: @@ -128,7 +124,6 @@ async def test_RO_100_should_not_throw_if_request_was_cancelled_by_the_page( assert cancelled_request.failure assert re.search(r"cancelled|aborted", cancelled_request.failure, re.I) await route.fulfill(status=200) # Should not throw. - await evaluate_task async def test_RO_101_should_allow_mocking_binary_responses( diff --git a/tests/async/page/test_page_route.py b/tests/async/page/test_page_route.py index baa67e81b..885d623cd 100644 --- a/tests/async/page/test_page_route.py +++ b/tests/async/page/test_page_route.py @@ -301,52 +301,60 @@ async def test_RO_012_should_pause_intercepted_xhr_until_continue( pytest.skip("fixme: Redirected request is not paused in WebKit") await page.goto(server.EMPTY_PAGE) + route_promise: "asyncio.Future[Route]" = asyncio.Future() + await page.route( + "**/global-var.html", lambda route: route_promise.set_result(route) + ) xhr_finished = False - xhr_finished_while_paused: List[bool] = [] - async def handler(route: Route) -> None: - # Check that intercepted request is actually paused. - await page.wait_for_timeout(500) - xhr_finished_while_paused.append(xhr_finished) - await route.continue_() + def request_finished(request: Request) -> None: + nonlocal xhr_finished + if request.url.endswith("/global-var.html"): + xhr_finished = True - await page.route("**/global-var.html", handler) - status = await page.evaluate( - """async () => { - const request = new XMLHttpRequest(); - request.open('GET', '/global-var.html', false); // `false` makes the request synchronous - request.send(null); - return request.status; - }""" + page.on("requestfinished", request_finished) + # The synchronous request blocks the page, so it is started from a timer. + await page.evaluate( + """() => { + window.__statusPromise = new Promise(resolve => setTimeout(() => { + const request = new XMLHttpRequest(); + request.open('GET', '/global-var.html', false); // `false` makes the request synchronous + request.send(null); + resolve(request.status); + }, 0)); + }""" ) - xhr_finished = True - assert xhr_finished_while_paused == [False] - assert status == 200 + route = await route_promise + # Check that intercepted request is actually paused. + await asyncio.sleep(0.5) + assert xhr_finished is False + await route.continue_() + assert await page.evaluate("() => window.__statusPromise") == 200 async def test_RO_013_should_pause_intercepted_fetch_request_until_continue( page: Page, server: Server ) -> None: await page.goto(server.EMPTY_PAGE) - fetch_finished = False - fetch_finished_while_paused: List[bool] = [] - - async def handler(route: Route) -> None: - # Check that intercepted request is actually paused. - await page.wait_for_timeout(500) - fetch_finished_while_paused.append(fetch_finished) - await route.continue_() - - await page.route("**/global-var.html", handler) - status = await page.evaluate( - """async () => { - const response = await fetch('/global-var.html'); - return response.status; - }""" + route_promise: "asyncio.Future[Route]" = asyncio.Future() + await page.route( + "**/global-var.html", lambda route: route_promise.set_result(route) ) - fetch_finished = True - assert fetch_finished_while_paused == [False] - assert status == 200 + await page.evaluate( + """() => { + window.__fetchFinished = false; + window.__statusPromise = fetch('/global-var.html').then(response => { + window.__fetchFinished = true; + return response.status; + }); + }""" + ) + route = await route_promise + # Check that intercepted request is actually paused. + await asyncio.sleep(0.5) + assert await page.evaluate("() => window.__fetchFinished") is False + await route.continue_() + assert await page.evaluate("() => window.__statusPromise") == 200 async def test_RO_014_should_work_with_custom_referer_headers( @@ -1511,3 +1519,26 @@ async def handler(route: Route) -> None: await page.evaluate("() => window['activationPromise']") await page.goto(url) assert interceptions == 2 + + +async def test_RO_202_should_continue_a_request_reported_while_interception_was_disabled( + page: Page, server: Server +) -> None: + await page.goto(server.PREFIX + "/serviceworkers/slow-fallback/sw.html") + await page.evaluate("() => window['activationPromise']") + + async def handler(route: Route) -> None: + await route.fulfill(body="mocked") + + await page.route("**/never-requested", handler) + await page.unroute("**/never-requested") + # The service worker holds the request, so it reaches the network + # after interception is enabled again. + async with page.expect_request("**/simple.json"): + body_promise = asyncio.create_task( + page.evaluate( + "() => fetch('/simple.json').then(response => response.text())" + ) + ) + await page.route("**/never-requested", handler) + assert await body_promise == '{"foo": "bar"}\n' diff --git a/tests/async/page/test_page_screenshot.py b/tests/async/page/test_page_screenshot.py index aefdbd546..e920cd5ba 100644 --- a/tests/async/page/test_page_screenshot.py +++ b/tests/async/page/test_page_screenshot.py @@ -711,6 +711,29 @@ async def screenshot_several_times() -> None: @pytest.mark.usefixtures("skip_firefox_headed") +async def test_SS_096_page_screenshot_should_not_wait_for_unused_font_face_with_failed_local_source( + page: Page, server: Server, browser_name: str +) -> None: + if browser_name == "webkit": + pytest.skip("Fixed upstream in https://commits.webkit.org/323883@main") + + await page.goto(server.EMPTY_PAGE) + await page.set_content( + f""" + +
hello
+ """ + ) + await page.screenshot() + assert await page.evaluate("() => document.fonts.status") == "loaded" + + async def test_SS_040_page_screenshot_mask_option_should_work( page: Page, server: Server, assert_to_be_golden: GoldenAssert ) -> None: diff --git a/tests/async/page/test_page_set_input_files.py b/tests/async/page/test_page_set_input_files.py index 6913f7d4f..946a395f6 100644 --- a/tests/async/page/test_page_set_input_files.py +++ b/tests/async/page/test_page_set_input_files.py @@ -504,3 +504,37 @@ async def test_IF_021_should_preserve_last_modified_timestamp( assert ( abs(timestamps[i] - expected_timestamps[i]) <= 1000 ), f"expected: {expected_timestamps}; actual: {timestamps}" + + +async def test_IF_022_should_store_an_uploaded_file_in_indexed_db( + page: Page, server: Server, assetdir: Path, browser_name: str +) -> None: + if browser_name == "webkit": + pytest.skip( + "WebKit does not support storing files in IndexedDB in private browsing." + ) + + await page.goto(server.PREFIX + "/input/fileupload.html") + input = page.locator("input[type=file]") + await input.set_input_files(assetdir / "file-to-upload.txt") + stored_file = await input.evaluate( + """async input => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open('file-upload'); + request.onupgradeneeded = () => request.result.createObjectStore('files'); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const file = await new Promise((resolve, reject) => { + const transaction = db.transaction('files', 'readwrite'); + const store = transaction.objectStore('files'); + store.put(input.files[0], 'file'); + const request = store.get('file'); + transaction.oncomplete = () => resolve(request.result); + transaction.onabort = () => reject(transaction.error); + }); + db.close(); + return { name: file.name, text: await file.text() }; + }""" + ) + assert stored_file == {"name": "file-to-upload.txt", "text": "contents of the file"} diff --git a/tests/async/page/test_retarget.py b/tests/async/page/test_retarget.py index 48cb29954..e2ac1b40e 100644 --- a/tests/async/page/test_retarget.py +++ b/tests/async/page/test_retarget.py @@ -208,6 +208,20 @@ async def test_RE_007_enabled_disabled_retargeting(page: Page) -> None: False, "li", ), + ( + dom_in_label( + "" + ), + False, + "option >> nth=0", + ), + ( + dom_in_label( + '' + ), + False, + "optgroup", + ), ] for dom, enabled, locator in cases: # Step: '"{locator}" in "{dom}" should be enabled={enabled}'. diff --git a/tests/async/page/test_selectors_role.py b/tests/async/page/test_selectors_role.py index 65353cff5..f3749552d 100644 --- a/tests/async/page/test_selectors_role.py +++ b/tests/async/page/test_selectors_role.py @@ -495,7 +495,9 @@ async def test_SE_059_should_filter_hidden_unless_explicitly_asked_for( ] -async def test_SE_060_should_support_name(page: Page) -> None: +async def test_SE_060_should_support_name( + page: Page, browser_name: str, browser_major_version: int +) -> None: await page.set_content( """
@@ -503,8 +505,13 @@ async def test_SE_060_should_support_name(page: Page) -> None: + + Chosen name +
""" ) + group = page.get_by_role("group") + await expect(group).to_have_accessible_name("Chosen name") assert await page.locator('role=button[name="Hello"]').evaluate_all(OUTERS) == [ '
', ] @@ -578,6 +585,22 @@ async def test_SE_060_should_support_name(page: Page) -> None: '', ] + # Old electron/android do not have this API. + is_missing_aria_labelled_by_elements = ( + browser_name == "chromium" and browser_major_version < 135 + ) + if not is_missing_aria_labelled_by_elements: + await group.evaluate( + """element => { + element.ariaLabelledByElements = [document.querySelector('#title')]; + }""" + ) + assert await page.get_by_role( + "group", name="Chosen name", exact=True + ).evaluate_all(OUTERS) == [ + '
', + ] + async def test_SE_061_should_support_option_name_with_html_whitespace( page: Page, diff --git a/tests/sync/library/chromium/test_connect_over_cdp.py b/tests/sync/library/chromium/test_connect_over_cdp.py index 203d97f5f..71d74a89b 100644 --- a/tests/sync/library/chromium/test_connect_over_cdp.py +++ b/tests/sync/library/chromium/test_connect_over_cdp.py @@ -17,6 +17,7 @@ import json import os import signal +import time import urllib.request from concurrent.futures import ThreadPoolExecutor from contextlib import suppress @@ -25,6 +26,7 @@ import pytest +from playwright._impl._greenlets import wait_for_future from playwright.sync_api import ( Browser, BrowserType, @@ -916,3 +918,52 @@ def test_CD_035_no_defaults_should_not_affect_new_contexts( browser.close() finally: browser_server.close() + + +def test_CD_053_should_connect_when_another_client_inspects_a_terminated_shared_worker( + browser_type: BrowserType, server: Server, launch_arguments: Dict, port: int +) -> None: + pytest.skip("fixme") + browser = launch(browser_type, launch_arguments, port) + try: + # Another client inspects the shared worker. + page = browser.new_page() + inspector = browser.new_browser_cdp_session() + worker_created = browser._loop.create_future() + + def on_target_created(event: Dict[str, Any]) -> None: + if event["targetInfo"]["type"] == "shared_worker": + worker_created.set_result(event["targetInfo"]["targetId"]) + + inspector.on("Target.targetCreated", on_target_created) + inspector.send("Target.setDiscoverTargets", {"discover": True}) + page.goto(server.PREFIX + "/shared-worker/shared-worker.html") + assert page.evaluate("window.sharedWorkerResponsePromise") == "echo:hello" + # Python: run the event loop until the shared worker's target is known. + wait_for_future(browser._loop, browser._dispatcher_fiber, worker_created) + worker_target_id = worker_created.result() + inspector.send( + "Target.attachToTarget", {"targetId": worker_target_id, "flatten": True} + ) + + # The last client of the shared worker goes away, so the worker is terminated. + # The inspector stays attached, so the worker target lingers without a browser context. + page.close() + # Python: poll the CDP target list instead of expect.poll. + deadline = time.monotonic() + 5 + while True: + targets = inspector.send("Target.getTargets") + if not any( + info["targetId"] == worker_target_id for info in targets["targetInfos"] + ): + break + assert time.monotonic() < deadline + time.sleep(0.1) + + # A new client auto-attaches to the lingering worker target. + cdp_browser = browser_type.connect_over_cdp(f"http://127.0.0.1:{port}/") + page2 = cdp_browser.contexts[0].new_page() + assert page2.evaluate("1 + 1") == 2 + cdp_browser.close() + finally: + browser.close() diff --git a/tests/sync/library/chromium/test_oopif.py b/tests/sync/library/chromium/test_oopif.py index 0bc7984a9..edb2d4b4c 100644 --- a/tests/sync/library/chromium/test_oopif.py +++ b/tests/sync/library/chromium/test_oopif.py @@ -297,11 +297,13 @@ def test_OO_012_should_take_screenshot( browser: Browser, server: Server, assert_to_be_golden: Callable[[bytes, str], None], + utils: Utils, ) -> None: page.set_viewport_size({"width": 500, "height": 500}) page.goto(server.PREFIX + "/dynamic-oopif.html") assert len(page.frames) == 2 assert_oopif_count(browser, 1) + utils.rafraf(page.frames[1]) assert_to_be_golden(page.screenshot(), "screenshot-oopif.png") diff --git a/tests/sync/library/test_browsercontext_har.py b/tests/sync/library/test_browsercontext_har.py index 79c56d6f0..c6c39d1e9 100644 --- a/tests/sync/library/test_browsercontext_har.py +++ b/tests/sync/library/test_browsercontext_har.py @@ -14,6 +14,7 @@ """Port of tests/library/browsercontext-har.spec.ts.""" +import io import json import re import zipfile @@ -300,6 +301,24 @@ def test_HA_085_should_round_trip_har_zip( expect(page2.locator("body")).to_have_css("background-color", "rgb(255, 192, 203)") +def test_HA_114_should_reject_a_malformed_har_zip( + context_factory: Callable[..., BrowserContext], tmp_path: Path +) -> None: + # Python: zipfile builds the in-memory archive instead of yazl's output stream. + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as zip_file: + zip_file.writestr("aa/x.har", b"{}") + # yauzl reports '..' in an entry name as an 'error' event while reading entries. + zip_data = output.getvalue().replace(b"aa/x.har", b"../x.har") + har_path = tmp_path / "malformed.har.zip" + har_path.write_bytes(zip_data) + + context = context_factory() + with pytest.raises(Error) as exc_info: + context.route_from_har(har_path) + assert "invalid relative path: ../x.har" in exc_info.value.message + + def test_HA_086_should_produce_extracted_zip( context_factory: Callable[..., BrowserContext], server: Server, diff --git a/tests/sync/library/test_browsercontext_user_agent.py b/tests/sync/library/test_browsercontext_user_agent.py index 13f57a996..81e741cd0 100644 --- a/tests/sync/library/test_browsercontext_user_agent.py +++ b/tests/sync/library/test_browsercontext_user_agent.py @@ -18,7 +18,8 @@ import pytest -from playwright.sync_api import Browser, BrowserContext, Error, Page, Playwright +from playwright._impl._greenlets import wait_for_future +from playwright.sync_api import Browser, BrowserContext, Error, Page, Playwright, Route from tests.server import ExpectResponse, Server, TestServerRequest from tests.utils import chromium_version_less_than, must @@ -51,6 +52,36 @@ def test_UA_001_should_work(browser: Browser, server: Server) -> None: context.close() +def test_UA_007_should_preserve_custom_user_agent_in_route_fetch( + context_factory: Callable[..., BrowserContext], + server: Server, + sync_gather: Callable, +) -> None: + context = context_factory(user_agent="my-custom-user-agent") + page = context.new_page() + + def handle(route: Route) -> None: + route.fulfill(response=route.fetch()) + + page.route("**/empty.html", handle) + + # Python: the server's async waiter uses the page's event loop. + def server_request() -> TestServerRequest: + future = page._loop.create_task(server.wait_for_request("/empty.html")) + wait_for_future(page._loop, page._dispatcher_fiber, future) + return future.result() + + request, received_request, _ = sync_gather( + lambda: page.wait_for_event( + "request", predicate=lambda r: r.url == server.EMPTY_PAGE + ), + server_request, + lambda: page.goto(server.EMPTY_PAGE), + ) + assert request.headers["user-agent"] == "my-custom-user-agent" + assert received_request.getHeader("user-agent") == "my-custom-user-agent" + + def test_UA_002_should_work_for_subframes( browser: Browser, server: Server, utils: Utils ) -> None: diff --git a/tests/sync/library/test_capabilities.py b/tests/sync/library/test_capabilities.py index e02672b63..7e9d3c2f5 100644 --- a/tests/sync/library/test_capabilities.py +++ b/tests/sync/library/test_capabilities.py @@ -180,26 +180,19 @@ def test_CA_011_should_not_crash_on_show_directory_picker( page.goto(server.EMPTY_PAGE) # "User activation is required to show a file picker." - so we click first. page.locator("body").click() - # The sync API can not leave page.evaluate() running: the page starts the picker - # and keeps its error. page.evaluate( """() => { - window.pickerError = undefined; - (async () => { - const dir = await window.showDirectoryPicker(); - return dir.name; - // In headless it throws (aborted), in headed it stalls (Test ended) and waits for the picker to be accepted. - })().catch(e => window.pickerError = String(e)); + window.showDirectoryPicker().catch(e => { window.__pickerError = String(e); }); }""" ) # The dialog will not be accepted, so we just wait for some time to # to give the browser a chance to crash. page.wait_for_timeout(3_000) - error = page.evaluate("() => window.pickerError") - if error is not None: - assert re.search( - r"(DOMException|AbortError): .*The user aborted a request", error - ) + # In headless it throws (aborted), in headed it stalls and waits for the picker to be accepted. + error = page.evaluate("() => window.__pickerError ?? 'stalled'") + assert re.search( + r"((DOMException|AbortError): .*The user aborted a request|stalled)", error + ) def test_CA_012_should_not_crash_on_storage_get_directory( diff --git a/tests/sync/library/test_multiclient.py b/tests/sync/library/test_multiclient.py index 6afb3d6d6..2dab18eb4 100644 --- a/tests/sync/library/test_multiclient.py +++ b/tests/sync/library/test_multiclient.py @@ -547,3 +547,51 @@ def test_MC_018_should_stop_tracing_upon_disconnect( disconnect(playwright, page_a) page_b.context.tracing.start() + + +@pytest.mark.slow +def test_MC_020_should_run_locator_handlers_after_reconnecting_with_a_pending_callback( + playwright: Playwright, + connect: Callable[..., Browser], + remote_server: RemoteServer, + server: Server, +) -> None: + browser = connect(remote_server.ws_endpoint) + page = browser.new_page() + page.goto(server.PREFIX + "/input/handle-locator.html") + + called = 0 + + def handler() -> None: + nonlocal called + called += 1 + # Python: an unresolved future keeps the sync callback pending. + wait_for_future(page._loop, page._dispatcher_fiber, page._loop.create_future()) + + page.add_locator_handler( + page.get_by_text("This interstitial covers the button"), handler + ) + page.evaluate( + """() => { + window.setupAnnoyingInterstitial('none', 1); + }""" + ) + with pytest.raises(Error) as exc_info: + page.locator("#target").click(timeout=1000) + assert "Timeout 1000ms exceeded" in exc_info.value.message + assert called == 1 + + disconnect(playwright, page) + + reconnected_browser = connect(remote_server.ws_endpoint) + reconnected_page = reconnected_browser.contexts[0].pages[0] + + def reconnected_handler() -> None: + reconnected_page.locator("#close").click() + + reconnected_page.add_locator_handler( + reconnected_page.get_by_text("This interstitial covers the button"), + reconnected_handler, + ) + reconnected_page.locator("#target").click(timeout=3000) + assert reconnected_page.evaluate("window.clicked") == 1 diff --git a/tests/sync/library/test_popup.py b/tests/sync/library/test_popup.py index 3817ca81c..05f12d7ad 100644 --- a/tests/sync/library/test_popup.py +++ b/tests/sync/library/test_popup.py @@ -217,6 +217,27 @@ def handle(route: Route) -> None: context.close() +def test_PO_029_should_load_subresources_of_an_about_blank_popup_when_the_opener_has_routes( + browser: Browser, server: Server, browser_name: str +) -> None: + if browser_name == "chromium": + pytest.skip( + "Subresources of an about:blank popup are paused on the opener target and never continued" + ) + context = browser.new_context() + page = context.new_page() + page.route("**/*", lambda route: route.continue_()) + page.goto(server.EMPTY_PAGE) + with page.expect_popup() as popup_info: + page.evaluate( + """url => window.open('').document.write(``)""", + server.PREFIX + "/pptr.png", + ) + popup = popup_info.value + popup.wait_for_function("() => document.querySelector('img')?.naturalWidth > 0") + context.close() + + def test_PO_023_browser_context_add_init_script_should_apply_to_an_in_process_popup( browser: Browser, server: Server ) -> None: diff --git a/tests/sync/library/test_tracing.py b/tests/sync/library/test_tracing.py index 6f08f8a1b..ced468124 100644 --- a/tests/sync/library/test_tracing.py +++ b/tests/sync/library/test_tracing.py @@ -1257,23 +1257,15 @@ def test_TR_041_should_not_emit_after_w_o_before( page = context.new_page() context.tracing.start(name="name1", snapshots=True) - evaluate_promise = run_in_background( - page, - lambda: page.evaluate( - """() => { - console.log('started'); - return new Promise(f => window.callback = f); - }""" - ), - ) - with page.expect_event("console"): - pass + # The wait spans the chunks: its "before" is in the first chunk, and its "after" must not get into the second one. + console_promise = run_in_background(page, lambda: page.wait_for_event("console")) + page.evaluate("() => 1") context.tracing.stop_chunk(path=tmp_path / "trace1.zip") assert (traces_dir / "name1.trace").exists() is True context.tracing.start_chunk(name="name2") - page.evaluate_handle("() => window.callback()") - wait_for(page, evaluate_promise) + page.evaluate("() => console.log('started')") + wait_for(page, console_promise) context.tracing.stop(path=tmp_path / "trace2.zip") assert (traces_dir / "name2.trace").exists() is True @@ -1296,12 +1288,13 @@ def sanitize(e: Dict[str, Any]) -> Optional[Dict[str, Any]]: events = parse_trace_raw(tmp_path / "trace1.zip").events sanitized = [s for s in map(sanitize, events) if s] assert [{k: v for k, v in s.items() if k != "callId"} for s in sanitized] == [ - {"type": "before"}, {"type": "before", "title": 'Wait for event "console"'}, + {"type": "before"}, {"type": "after"}, ] - call1 = sanitized[0]["callId"] + assert sanitized[1]["callId"] > sanitized[0]["callId"] assert sanitized[1]["callId"] == sanitized[2]["callId"] + call1 = sanitized[1]["callId"] events = parse_trace_raw(tmp_path / "trace2.zip").events sanitized = [s for s in map(sanitize, events) if s] diff --git a/tests/sync/library/test_webmcp.py b/tests/sync/library/test_webmcp.py index ba5c9ca73..5a23711e1 100644 --- a/tests/sync/library/test_webmcp.py +++ b/tests/sync/library/test_webmcp.py @@ -14,6 +14,7 @@ """Port of tests/library/webmcp.spec.ts.""" +import re from typing import Any, Callable, Dict, Generator import pytest @@ -200,9 +201,14 @@ def test_WM_005_should_throw_when_the_tool_throws( with pytest.raises(Error) as exc_info: page.webmcp.call_tool("throwing") # Chromium does not report the error that the tool threw. - assert ( - "the invocation failed" if browser_name == "chromium" else "boom" - ) in exc_info.value.message + assert re.search( + ( + r"the invocation failed|Tool execution failed" + if browser_name == "chromium" + else r"boom" + ), + exc_info.value.message, + ) def test_WM_006_should_throw_for_an_unknown_tool(page: Page, server: Server) -> None: diff --git a/tests/sync/page/test_elementhandle_scroll_into_view.py b/tests/sync/page/test_elementhandle_scroll_into_view.py index 7c2d4c2d7..871e49068 100644 --- a/tests/sync/page/test_elementhandle_scroll_into_view.py +++ b/tests/sync/page/test_elementhandle_scroll_into_view.py @@ -108,6 +108,40 @@ def test_EH_096_should_scroll_display_contents_into_view( assert abs(scroll_top - 350) < 1 +def test_EH_124_should_scroll_nested_display_contents_into_view( + page: Page, browser_name: str, browser_major_version: int +) -> None: + page.set_content( + """ + +
+
+
Hello
Sibling
+
+
+ """ + ) + div = page.query_selector("#outer") + assert div + div.scroll_into_view_if_needed() + # The first box in the flattened subtree of #outer is #target, two levels down, + # not the tall sibling. No whitespace text nodes inside #outer: in Firefox they + # get boxes of their own, which would hide a fallback that only looks one level down. + target_is_visible = page.eval_on_selector( + "#target", + """target => { + const container = document.getElementById('container').getBoundingClientRect(); + const rect = target.getBoundingClientRect(); + return rect.top >= container.top && rect.bottom <= container.bottom; + }""", + ) + assert target_is_visible is True + + def test_EH_097_should_work_for_visibility_hidden_element(page: Page) -> None: page.set_content('
Hello
') div = page.query_selector("div") diff --git a/tests/sync/page/test_page_add_locator_handler.py b/tests/sync/page/test_page_add_locator_handler.py index d7c5ed2f9..59418cc5e 100644 --- a/tests/sync/page/test_page_add_locator_handler.py +++ b/tests/sync/page/test_page_add_locator_handler.py @@ -14,10 +14,12 @@ """Port of tests/page/page-add-locator-handler.spec.ts.""" +import asyncio from typing import List, Union import pytest +from playwright._impl._greenlets import wait_for_future from playwright.sync_api import Error, Locator, Page, expect from tests.server import Server @@ -147,15 +149,20 @@ def test_LH_005_should_throw_when_handler_times_out(page: Page, server: Server) page.goto(server.PREFIX + "/input/handle-locator.html") called = 0 + # Python: futures stand for the two ManualPromises. + release_handler: "asyncio.Future[None]" = page._loop.create_future() + handler_finished: "asyncio.Future[Union[Error, None]]" = page._loop.create_future() def handler() -> None: nonlocal called called += 1 # Deliberately timeout. + wait_for_future(page._loop, page._dispatcher_fiber, release_handler) try: - page.wait_for_timeout(9999999) - except Exception: - pass + page.locator("#close").click(timeout=3000) + handler_finished.set_result(None) + except Error as error: + handler_finished.set_result(error) page.add_locator_handler( page.get_by_text("This interstitial covers the button"), handler @@ -178,6 +185,9 @@ def handler() -> None: # Should not enter the same handler while it is still running. assert called == 1 + release_handler.set_result(None) + wait_for_future(page._loop, page._dispatcher_fiber, handler_finished) + assert handler_finished.result() is None def test_LH_006_should_work_with_to_be_visible(page: Page, server: Server) -> None: diff --git a/tests/sync/page/test_page_aria_snapshot_ai.py b/tests/sync/page/test_page_aria_snapshot_ai.py index f2d8eaebc..ceb4fff2b 100644 --- a/tests/sync/page/test_page_aria_snapshot_ai.py +++ b/tests/sync/page/test_page_aria_snapshot_ai.py @@ -187,6 +187,14 @@ def test_AR_052_should_stitch_all_frame_snapshots(page: Page, server: Server) -> page.get_by_ref("e1000").normalize() assert "No element matching aria-ref=e1000" in exc_info.value.message + page.locator("iframe").evaluate_all( + """iframes => { + for (const iframe of iframes) + iframe.textContent = 'Your browser does not support frames'; + }""" + ) + assert snapshot_for_ai(page) == snapshot + def test_AR_053_should_re_number_refs_across_navigations_but_not_same_document_navigations( page: Page, server: Server diff --git a/tests/sync/page/test_page_drag.py b/tests/sync/page/test_page_drag.py index e0029d112..9e2f2e039 100644 --- a/tests/sync/page/test_page_drag.py +++ b/tests/sync/page/test_page_drag.py @@ -15,7 +15,7 @@ """Port of tests/page/page-drag.spec.ts.""" import time -from typing import Any, Callable, List, Optional +from typing import Any, List, Optional import pytest @@ -647,21 +647,15 @@ def test_DD_018_should_report_event_buttons(page: Page) -> None: def test_DD_019_should_handle_custom_data_transfer( - page: Page, browser_name: str, is_win: bool, sync_gather: Callable + page: Page, browser_name: str, is_win: bool ) -> None: if browser_name == "webkit" and is_win: pytest.skip("fixme") page.set_content("""""") - def drag() -> None: - page.hover('[draggable="true"]') - page.mouse.down() - page.mouse.move(100, 100) - page.mouse.up() - - result, _ = sync_gather( - lambda: page.evaluate( - """() => new Promise(resolve => { + page.evaluate( + """() => { + window.__result = new Promise(resolve => { document.addEventListener('dragstart', event => { event.dataTransfer.setData('custom-type', 'Hello World'); }, false); @@ -680,12 +674,16 @@ def drag() -> None: data: event.dataTransfer.getData('custom-type'), }); }, false); - })""" - ), - drag, + }); + }""" ) - assert result == { + page.hover('[draggable="true"]') + page.mouse.down() + page.mouse.move(100, 100) + page.mouse.up() + + assert page.evaluate("() => window.__result") == { "types": ["custom-type"], "data": "Hello World", } diff --git a/tests/sync/page/test_page_evaluate.py b/tests/sync/page/test_page_evaluate.py index 4ea73293a..00c480edd 100644 --- a/tests/sync/page/test_page_evaluate.py +++ b/tests/sync/page/test_page_evaluate.py @@ -691,24 +691,17 @@ def test_EV_062_should_simulate_a_user_gesture(page: Page) -> None: def test_EV_063_should_throw_a_nice_error_after_a_navigation( - page: Page, sync_gather: Callable + page: Page, ) -> None: - def wait_for_error() -> Error: + with page.expect_navigation(): with pytest.raises(Error) as exc_info: - page.evaluate("() => new Promise(f => window['__resolve'] = f)") - return exc_info.value - - def reload() -> None: - with page.expect_navigation(): page.evaluate( - """() => { + """() => new Promise(f => setTimeout(() => { window.location.reload(); - setTimeout(() => window['__resolve'](42), 1000); - }""" + setTimeout(() => f(42), 1000); + }, 0))""" ) - - error, _ = sync_gather(wait_for_error, reload) - assert "navigation" in error.message + assert "navigation" in exc_info.value.message def test_EV_064_should_not_throw_an_error_when_evaluation_does_a_navigation( diff --git a/tests/sync/page/test_page_event_network.py b/tests/sync/page/test_page_event_network.py index 8a7c3b91f..516fc4262 100644 --- a/tests/sync/page/test_page_event_network.py +++ b/tests/sync/page/test_page_event_network.py @@ -138,11 +138,13 @@ def test_NE_089_should_support_redirects(page: Page, server: Server) -> None: assert redirected_from.redirected_to == response.request -def test_NE_090_should_resolve_responses_after_a_navigation( +def test_NE_090_should_resolve_responses_after_a_cross_process_navigation( page: Page, server: Server, browser_name: str, sync_gather: Callable ) -> None: if browser_name == "chromium": - pytest.skip("fixme") + pytest.skip( + "Chromium does not report loadingFailed when the navigation swaps the frame" + ) responses_from_server: List[TestServerRequest] = [] server_got_request = threading.Event() @@ -178,3 +180,47 @@ def navigate() -> None: response, _ = sync_gather(lambda: request.response(), navigate) # the response should resolve to null, because the page navigated. assert response is None + + +def test_NE_143_should_resolve_responses_after_a_same_process_navigation( + page: Page, server: Server, browser_name: str, sync_gather: Callable +) -> None: + if browser_name == "chromium": + pytest.skip( + "Chromium does not report loadingFailed when the navigation swaps the frame" + ) + responses_from_server: List[TestServerRequest] = [] + server_got_request = threading.Event() + + def handle(server_request: TestServerRequest) -> None: + responses_from_server.append(server_request) + server_got_request.set() + + server.set_route("/foo", handle) + page.goto(server.EMPTY_PAGE) + with page.expect_request(lambda _: True) as request_info: + # start a long running request and wait until it hits the server + page.evaluate("url => void fetch(url)", server.PREFIX + "/foo") + while not server_got_request.is_set(): + page.wait_for_timeout(10) + response_from_server = responses_from_server[0] + request = request_info.value + + def end() -> None: + try: + response_from_server.write(b"done") + response_from_server.finish() + except RuntimeError: + # Python: Node's res.end() also accepts an already dropped request. + pass + + def navigate() -> None: + # navigate within the same process, which should cancel the request + page.goto(server.PREFIX + "/title.html") + # make sure we aren't stalling this request on the server + reactor.callFromThread(end) + + # Python: start the response wait before the navigation. + response, _ = sync_gather(lambda: request.response(), navigate) + # the response should resolve to null, because the page navigated. + assert response is None diff --git a/tests/sync/page/test_page_event_pageerror.py b/tests/sync/page/test_page_event_pageerror.py index 45908c5d4..bf113321c 100644 --- a/tests/sync/page/test_page_event_pageerror.py +++ b/tests/sync/page/test_page_event_pageerror.py @@ -29,7 +29,11 @@ def expect_failure(request: pytest.FixtureRequest, reason: str) -> None: def test_ER_001_should_fire( - page: Page, server: Server, browser_name: str, request: pytest.FixtureRequest + page: Page, + server: Server, + browser_name: str, + browser_major_version: int, + request: pytest.FixtureRequest, ) -> None: url = server.PREFIX + "/error.html" with page.expect_event("pageerror") as error_info: @@ -104,7 +108,7 @@ def handle_page(request: TestServerRequest) -> None: page.goto(server.PREFIX + "/cross-origin-error.html") cross_origin_error = cross_origin_error_info.value assert cross_origin_error.message == "Fancy error!" - if browser_name == "chromium": + if browser_name == "chromium" and browser_major_version >= 155: assert ( cross_origin_error.stack == f"""Uncaught Error: Fancy error! diff --git a/tests/sync/page/test_page_focus.py b/tests/sync/page/test_page_focus.py index 8c6ec3d5e..09dcbf45a 100644 --- a/tests/sync/page/test_page_focus.py +++ b/tests/sync/page/test_page_focus.py @@ -327,3 +327,21 @@ def test_FO_012_should_tab_from_an_iframe_to_its_parent( ) page.keyboard.press("Tab") expect(page.locator("#outer")).to_be_focused() + + +def test_FO_013_should_move_focus_from_the_parent_into_its_iframe( + page: Page, server: Server +) -> None: + _frame_route(server) + page.goto(server.EMPTY_PAGE) + page.set_content( + f'' + ) + page.locator("#outer").focus() + expect(page.locator("#outer")).to_be_focused() + # Call native focus() exactly once, locator.focus() would hide the bug by calling it twice. + page.evaluate( + "() => document.querySelector('iframe').contentDocument.querySelector('input').focus()" + ) + expect(page.locator("iframe")).to_be_focused() + expect(page.frame_locator("iframe").locator("#inner")).to_be_focused() diff --git a/tests/sync/page/test_page_request_fulfill.py b/tests/sync/page/test_page_request_fulfill.py index 3a18b26e6..2d59ddf31 100644 --- a/tests/sync/page/test_page_request_fulfill.py +++ b/tests/sync/page/test_page_request_fulfill.py @@ -115,31 +115,26 @@ def handler(route: Route) -> None: def test_RO_100_should_not_throw_if_request_was_cancelled_by_the_page( - page: Page, server: Server, sync_gather: Callable[..., List[Any]] + page: Page, server: Server ) -> None: intercept_future: "asyncio.Future[Route]" = asyncio.Future() page.route("**/data.json", lambda route: intercept_future.set_result(route)) page.goto(server.EMPTY_PAGE) - def fetch() -> None: - page.evaluate( - """url => { - globalThis.controller = new AbortController(); - return fetch(url, { signal: globalThis.controller.signal }); - }""", - server.PREFIX + "/data.json", - ) - - def cancel() -> None: - route = wait_for(page, intercept_future) - with page.expect_event("requestfailed") as failure_info: - page.evaluate("() => globalThis.controller.abort()") - cancelled_request: Request = failure_info.value - assert cancelled_request.failure - assert re.search(r"cancelled|aborted", cancelled_request.failure, re.I) - route.fulfill(status=200) # Should not throw. - - sync_gather(ignore_error(fetch), cancel) + page.evaluate( + """url => { + globalThis.controller = new AbortController(); + fetch(url, { signal: globalThis.controller.signal }).catch(() => {}); + }""", + server.PREFIX + "/data.json", + ) + route = wait_for(page, intercept_future) + with page.expect_event("requestfailed") as failure_info: + page.evaluate("() => globalThis.controller.abort()") + cancelled_request: Request = failure_info.value + assert cancelled_request.failure + assert re.search(r"cancelled|aborted", cancelled_request.failure, re.I) + route.fulfill(status=200) # Should not throw. def test_RO_101_should_allow_mocking_binary_responses( diff --git a/tests/sync/page/test_page_route.py b/tests/sync/page/test_page_route.py index 7a10557a2..6a52c89c8 100644 --- a/tests/sync/page/test_page_route.py +++ b/tests/sync/page/test_page_route.py @@ -14,6 +14,7 @@ """Port of tests/page/page-route.spec.ts.""" +import asyncio import json import re import time @@ -22,6 +23,7 @@ import pytest +from playwright._impl._greenlets import wait_for_future from playwright.sync_api import Error, Page, Request, Route, expect from tests.server import Server, TestServerRequest @@ -294,52 +296,60 @@ def test_RO_012_should_pause_intercepted_xhr_until_continue( pytest.skip("fixme: Redirected request is not paused in WebKit") page.goto(server.EMPTY_PAGE) + route_promise: "asyncio.Future[Route]" = page._loop.create_future() + page.route("**/global-var.html", lambda route: route_promise.set_result(route)) xhr_finished = False - xhr_finished_while_paused: List[bool] = [] - def handler(route: Route) -> None: - # Check that intercepted request is actually paused. - page.wait_for_timeout(500) - xhr_finished_while_paused.append(xhr_finished) - route.continue_() + def request_finished(request: Request) -> None: + nonlocal xhr_finished + if request.url.endswith("/global-var.html"): + xhr_finished = True - page.route("**/global-var.html", handler) - status = page.evaluate( - """async () => { - const request = new XMLHttpRequest(); - request.open('GET', '/global-var.html', false); // `false` makes the request synchronous - request.send(null); - return request.status; - }""" + page.on("requestfinished", request_finished) + # The synchronous request blocks the page, so it is started from a timer. + page.evaluate( + """() => { + window.__statusPromise = new Promise(resolve => setTimeout(() => { + const request = new XMLHttpRequest(); + request.open('GET', '/global-var.html', false); // `false` makes the request synchronous + request.send(null); + resolve(request.status); + }, 0)); + }""" ) - xhr_finished = True - assert xhr_finished_while_paused == [False] - assert status == 200 + # Python: wait for the promise resolved by the route handler. + wait_for_future(page._loop, page._dispatcher_fiber, route_promise) + route = route_promise.result() + # Check that intercepted request is actually paused. + page.wait_for_timeout(500) + assert xhr_finished is False + route.continue_() + assert page.evaluate("() => window.__statusPromise") == 200 def test_RO_013_should_pause_intercepted_fetch_request_until_continue( page: Page, server: Server ) -> None: page.goto(server.EMPTY_PAGE) - fetch_finished = False - fetch_finished_while_paused: List[bool] = [] - - def handler(route: Route) -> None: - # Check that intercepted request is actually paused. - page.wait_for_timeout(500) - fetch_finished_while_paused.append(fetch_finished) - route.continue_() - - page.route("**/global-var.html", handler) - status = page.evaluate( - """async () => { - const response = await fetch('/global-var.html'); - return response.status; - }""" + route_promise: "asyncio.Future[Route]" = page._loop.create_future() + page.route("**/global-var.html", lambda route: route_promise.set_result(route)) + page.evaluate( + """() => { + window.__fetchFinished = false; + window.__statusPromise = fetch('/global-var.html').then(response => { + window.__fetchFinished = true; + return response.status; + }); + }""" ) - fetch_finished = True - assert fetch_finished_while_paused == [False] - assert status == 200 + # Python: wait for the promise resolved by the route handler. + wait_for_future(page._loop, page._dispatcher_fiber, route_promise) + route = route_promise.result() + # Check that intercepted request is actually paused. + page.wait_for_timeout(500) + assert page.evaluate("() => window.__fetchFinished") is False + route.continue_() + assert page.evaluate("() => window.__statusPromise") == 200 def test_RO_014_should_work_with_custom_referer_headers( @@ -1486,3 +1496,32 @@ def handler(route: Route) -> None: page.evaluate("() => window['activationPromise']") page.goto(url) assert interceptions == 2 + + +def test_RO_202_should_continue_a_request_reported_while_interception_was_disabled( + page: Page, server: Server, sync_gather: Callable[..., List[Any]] +) -> None: + page.goto(server.PREFIX + "/serviceworkers/slow-fallback/sw.html") + page.evaluate("() => window['activationPromise']") + + def handler(route: Route) -> None: + route.fulfill(body="mocked") + + page.route("**/never-requested", handler) + page.unroute("**/never-requested") + # The service worker holds the request, so it reaches the network + # after interception is enabled again. + with page.expect_request("**/simple.json") as request_info: + + def enable_interception() -> None: + request_info.value + page.route("**/never-requested", handler) + + # Python: run the evaluation and the later route registration concurrently. + body, _ = sync_gather( + lambda: page.evaluate( + "() => fetch('/simple.json').then(response => response.text())" + ), + enable_interception, + ) + assert body == '{"foo": "bar"}\n' diff --git a/tests/sync/page/test_page_screenshot.py b/tests/sync/page/test_page_screenshot.py index 200d4317e..95c698a9f 100644 --- a/tests/sync/page/test_page_screenshot.py +++ b/tests/sync/page/test_page_screenshot.py @@ -709,6 +709,29 @@ def screenshot_several_times() -> None: @pytest.mark.usefixtures("skip_firefox_headed") +def test_SS_096_page_screenshot_should_not_wait_for_unused_font_face_with_failed_local_source( + page: Page, server: Server, browser_name: str +) -> None: + if browser_name == "webkit": + pytest.skip("Fixed upstream in https://commits.webkit.org/323883@main") + + page.goto(server.EMPTY_PAGE) + page.set_content( + f""" + +
hello
+ """ + ) + page.screenshot() + assert page.evaluate("() => document.fonts.status") == "loaded" + + def test_SS_040_page_screenshot_mask_option_should_work( page: Page, server: Server, assert_to_be_golden: GoldenAssert ) -> None: diff --git a/tests/sync/page/test_page_set_input_files.py b/tests/sync/page/test_page_set_input_files.py index f9fe051c6..c81649dc8 100644 --- a/tests/sync/page/test_page_set_input_files.py +++ b/tests/sync/page/test_page_set_input_files.py @@ -495,3 +495,37 @@ def test_IF_021_should_preserve_last_modified_timestamp( assert ( abs(timestamps[i] - expected_timestamps[i]) <= 1000 ), f"expected: {expected_timestamps}; actual: {timestamps}" + + +def test_IF_022_should_store_an_uploaded_file_in_indexed_db( + page: Page, server: Server, assetdir: Path, browser_name: str +) -> None: + if browser_name == "webkit": + pytest.skip( + "WebKit does not support storing files in IndexedDB in private browsing." + ) + + page.goto(server.PREFIX + "/input/fileupload.html") + input = page.locator("input[type=file]") + input.set_input_files(assetdir / "file-to-upload.txt") + stored_file = input.evaluate( + """async input => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open('file-upload'); + request.onupgradeneeded = () => request.result.createObjectStore('files'); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const file = await new Promise((resolve, reject) => { + const transaction = db.transaction('files', 'readwrite'); + const store = transaction.objectStore('files'); + store.put(input.files[0], 'file'); + const request = store.get('file'); + transaction.oncomplete = () => resolve(request.result); + transaction.onabort = () => reject(transaction.error); + }); + db.close(); + return { name: file.name, text: await file.text() }; + }""" + ) + assert stored_file == {"name": "file-to-upload.txt", "text": "contents of the file"} diff --git a/tests/sync/page/test_retarget.py b/tests/sync/page/test_retarget.py index 54f67a5f1..1132c7950 100644 --- a/tests/sync/page/test_retarget.py +++ b/tests/sync/page/test_retarget.py @@ -208,6 +208,20 @@ def test_RE_007_enabled_disabled_retargeting(page: Page) -> None: False, "li", ), + ( + dom_in_label( + "" + ), + False, + "option >> nth=0", + ), + ( + dom_in_label( + '' + ), + False, + "optgroup", + ), ] for dom, enabled, locator in cases: # Step: '"{locator}" in "{dom}" should be enabled={enabled}'. diff --git a/tests/sync/page/test_selectors_role.py b/tests/sync/page/test_selectors_role.py index 875449ed6..fb66570e5 100644 --- a/tests/sync/page/test_selectors_role.py +++ b/tests/sync/page/test_selectors_role.py @@ -502,7 +502,9 @@ def test_SE_059_should_filter_hidden_unless_explicitly_asked_for( ] -def test_SE_060_should_support_name(page: Page) -> None: +def test_SE_060_should_support_name( + page: Page, browser_name: str, browser_major_version: int +) -> None: page.set_content( """
@@ -510,8 +512,13 @@ def test_SE_060_should_support_name(page: Page) -> None: + + Chosen name +
""" ) + group = page.get_by_role("group") + expect(group).to_have_accessible_name("Chosen name") assert page.locator('role=button[name="Hello"]').evaluate_all(OUTERS) == [ '
', ] @@ -581,6 +588,22 @@ def test_SE_060_should_support_name(page: Page) -> None: '', ] + # Old electron/android do not have this API. + is_missing_aria_labelled_by_elements = ( + browser_name == "chromium" and browser_major_version < 135 + ) + if not is_missing_aria_labelled_by_elements: + group.evaluate( + """element => { + element.ariaLabelledByElements = [document.querySelector('#title')]; + }""" + ) + assert page.get_by_role("group", name="Chosen name", exact=True).evaluate_all( + OUTERS + ) == [ + '
', + ] + def test_SE_061_should_support_option_name_with_html_whitespace( page: Page, diff --git a/tests/upstream_registry.md b/tests/upstream_registry.md index ad2c6a1c5..4ea0aac35 100644 --- a/tests/upstream_registry.md +++ b/tests/upstream_registry.md @@ -78,6 +78,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `GN` | Locator and selector generator | `library/{locator-generator, selector-generator}` | | `HA` | HAR | `library/{har, browsercontext-har, har-websocket}` | | `HE` | Headful | `library/headful` | +| `HS` | HTTP server | `library/unit/http-server` | | `HT` | Hit target | `library/hit-target` | | `IF` | Input files | `page/page-set-input-files` | | `IN` | Inspector | `library/inspector/{pause, recorder-api, console-api, title}` | @@ -765,6 +766,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `CD-050` | `library/chromium/session.spec.ts` | should emit close event when session is detached | | `CD-051` | `library/chromium/session.spec.ts` | should emit close event when page closes | | `CD-052` | `library/chromium/session.spec.ts` | should work with newBrowserCDPSession | +| `CD-053` | `library/chromium/connect-over-cdp.spec.ts` | should connect when another client inspects a terminated shared worker | ### CF — Code frame @@ -1999,6 +2001,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `EH-121` | `page/elementhandle-wait-for-element-state.spec.ts` | should wait for button with an aria-disabled parent | | `EH-122` | `page/elementhandle-wait-for-element-state.spec.ts` | should wait for stable position | | `EH-123` | `page/elementhandle-wait-for-element-state.spec.ts` | should wait for editable input | +| `EH-124` | `page/elementhandle-scroll-into-view.spec.ts` | should scroll nested display:contents into view | ### EM — Emulation @@ -2875,6 +2878,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `FO-010` | `page/page-focus.spec.ts` | keeps focus on element when attempting to focus a non-focusable element | | `FO-011` | `page/page-focus.spec.ts` | should move focus from an iframe to its parent | | `FO-012` | `page/page-focus.spec.ts` | should tab from an iframe to its parent | +| `FO-013` | `page/page-focus.spec.ts` | should move focus from the parent into its iframe | ### FR — Frame @@ -3157,6 +3161,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `HA-111` | `library/har-websocket.spec.ts` | should still allow routeWebSocket to modify messages when capturing HAR | | `HA-112` | `library/har-websocket.spec.ts` | should respect PLAYWRIGHT\_HAR\_NO\_WEBSOCKET\_FRAMES | | `HA-113` | `library/har.spec.ts` | should store binary content with a textual mime type as base64 | +| `HA-114` | `library/browsercontext-har.spec.ts` | should reject a malformed har.zip | ### HE — Headful @@ -3179,6 +3184,13 @@ subsystem, add a row to the Subsystems table. Then update this file: | `HE-015` | `library/headful.spec.ts` | headless and headful should use same default fonts | | `HE-016` | `library/headful.spec.ts` | should have the same hyphen rendering on headless and headed | +### HS — HTTP server + +| ID | File | Title | +|---|---|---| +| `HS-001` | `library/unit/http-server.spec.ts` | should not serve files through a symlink that leaves the folder | +| `HS-002` | `library/unit/http-server.spec.ts` | should close upgrade requests to unknown paths | + ### HT — Hit target | ID | File | Title | @@ -3231,6 +3243,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `IF-019` | `page/page-set-input-files.spec.ts` | input event.composed should be true and cross shadow dom boundary | | `IF-020` | `page/page-set-input-files.spec.ts` | input should trigger events when files changed second time | | `IF-021` | `page/page-set-input-files.spec.ts` | should preserve lastModified timestamp | +| `IF-022` | `page/page-set-input-files.spec.ts` | should store an uploaded file in IndexedDB | ### IN — Inspector @@ -3727,6 +3740,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `MC-017` | `library/multiclient.spec.ts` | screencast should deliver cached last frame to a new client | | `MC-018` | `library/multiclient.spec.ts` | should stop tracing upon disconnect | | `MC-019` | `library/multiclient.spec.ts` | should not deliver an action buffered before the second recording started | +| `MC-020` | `library/multiclient.spec.ts` | should run locator handlers after reconnecting with a pending callback | ### MO — Mouse @@ -3950,7 +3964,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `NE-087` | `page/page-event-network.spec.ts` | Page.Events.RequestFinished @smoke | | `NE-088` | `page/page-event-network.spec.ts` | should fire events in proper order | | `NE-089` | `page/page-event-network.spec.ts` | should support redirects | -| `NE-090` | `page/page-event-network.spec.ts` | should resolve responses after a navigation | +| `NE-090` | `page/page-event-network.spec.ts` | should resolve responses after a cross-process navigation | | `NE-091` | `page/page-event-request.spec.ts` | should fire for navigation requests | | `NE-092` | `page/page-event-request.spec.ts` | should fire for iframes | | `NE-093` | `page/page-event-request.spec.ts` | should fire for fetches | @@ -4003,6 +4017,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `NE-140` | `library/ignorehttpserrors.spec.ts` | should work with WebSocket | | `NE-141` | `library/ignorehttpserrors.spec.ts` | should fail with WebSocket if not ignored | | `NE-142` | `library/ignorehttpserrors.spec.ts` | serviceWorker should intercept document request | +| `NE-143` | `page/page-event-network.spec.ts` | should resolve responses after a same-process navigation | ### OO — OOPIF @@ -4172,6 +4187,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `PO-026` | `library/popup.spec.ts` | should not dispatch binding on a closed page | | `PO-027` | `library/popup.spec.ts` | should not throttle rAF in the opener page | | `PO-028` | `library/popup.spec.ts` | should not throw when click closes popup | +| `PO-029` | `library/popup.spec.ts` | should load subresources of an about:blank popup when the opener has routes | ### PR — Proxy @@ -4528,6 +4544,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `RO-199` | `library/unroute-behavior.spec.ts` | route.fulfill should not throw if page has been closed | | `RO-200` | `library/unroute-behavior.spec.ts` | should not continue requests in flight (page) | | `RO-201` | `library/unroute-behavior.spec.ts` | should not continue requests in flight (context) | +| `RO-202` | `page/page-route.spec.ts` | should continue a request reported while interception was disabled | ### SC — Screencast @@ -4980,6 +4997,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `SS-093` | `library/screenshot.spec.ts` | element screenshot › should capture full element when larger than viewport with device scale factor and scale:css | | `SS-094` | `library/screenshot.spec.ts` | element screenshot › page screenshot should capture css transform with device pixels | | `SS-095` | `page/page-screenshot.spec.ts` | page screenshot › should capture webgl canvas after its drawing buffer is resized | +| `SS-096` | `page/page-screenshot.spec.ts` | page screenshot › should not wait for unused font face with failed local() source | ### ST — Storage @@ -5232,6 +5250,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `TV-133` | `library/snapshot-renderer.spec.ts` | snapshot renderer still renders ordinary custom elements | | `TV-134` | `library/snapshot-renderer.spec.ts` | stripAnsiEscapes should not exhibit polynomial backtracking | | `TV-135` | `library/snapshot-renderer.spec.ts` | stripAnsiEscapes handles common ANSI sequences after fix | +| `TV-136` | `library/snapshot-renderer.spec.ts` | snapshot renderer selects the nearest out-of-order screencast frame | ### TZ — Timezone @@ -5254,6 +5273,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `UA-004` | `library/browsercontext-user-agent.spec.ts` | should make a copy of default options | | `UA-005` | `library/browsercontext-user-agent.spec.ts` | custom user agent for download | | `UA-006` | `library/browsercontext-user-agent.spec.ts` | should work for navigator.userAgentData and sec-ch-ua headers | +| `UA-007` | `library/browsercontext-user-agent.spec.ts` | should preserve custom user agent in route.fetch | ### VI — Video