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('
+ """
+ )
+ 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