Skip to content

Commit e763f89

Browse files
committed
fix: make on_frame sync, use test fixtures, add DisposableStub context manager
1 parent ef619d3 commit e763f89

8 files changed

Lines changed: 224 additions & 18 deletions

File tree

playwright/_impl/_debugger.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020

2121
class Debugger(ChannelOwner):
22-
Events = {"PausedStateChanged": "pausedStateChanged"}
22+
Events = {"PausedStateChanged": "pausedstatechanged"}
2323

2424
def __init__(
2525
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict

playwright/_impl/_disposable.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ def __init__(self, dispose_fn: Callable[[], Awaitable[None]]) -> None:
4848
async def dispose(self) -> None:
4949
await self._dispose_fn()
5050

51+
async def __aenter__(self) -> "DisposableStub":
52+
return self
53+
54+
async def __aexit__(self, *args: object) -> None:
55+
await self.dispose()
56+
5157
async def close(self) -> None:
5258
await self.dispose()
5359

playwright/_impl/_screencast.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import base64
1516
from pathlib import Path
1617
from typing import (
1718
TYPE_CHECKING,
18-
Any,
19-
Awaitable,
2019
Callable,
2120
Dict,
2221
Literal,
@@ -36,21 +35,22 @@
3635
class Screencast:
3736
def __init__(self, page: "Page") -> None:
3837
self._page = page
38+
self._loop = page._loop
3939
self._started = False
4040
self._save_path: Optional[Union[str, Path]] = None
41-
self._on_frame: Optional[Callable[[ScreencastFrame], Awaitable[Any]]] = None
41+
self._on_frame: Optional[Callable[[ScreencastFrame], None]] = None
4242
self._artifact: Optional[Artifact] = None
4343
self._page._channel.on("screencastFrame", self._handle_frame)
4444

4545
def _handle_frame(self, params: Dict) -> None:
4646
if self._on_frame:
47-
self._on_frame({"data": params["data"]})
47+
self._on_frame({"data": base64.b64decode(params["data"])})
4848

4949
async def start(
5050
self,
5151
path: Union[str, Path] = None,
5252
quality: int = None,
53-
onFrame: Callable[[ScreencastFrame], Awaitable[Any]] = None,
53+
onFrame: Callable[[ScreencastFrame], None] = None,
5454
) -> DisposableStub:
5555
if self._started:
5656
raise Exception("Screencast is already started")
@@ -66,8 +66,8 @@ async def start(
6666
"record": path is not None,
6767
},
6868
)
69-
if result.get("artifact"):
70-
self._artifact = from_channel(result["artifact"])
69+
if result:
70+
self._artifact = from_channel(result)
7171
self._save_path = path
7272

7373
return DisposableStub(lambda: self.stop())
@@ -100,7 +100,7 @@ async def hide_actions(self) -> None:
100100
await self._page._channel.send("screencastHideActions", None)
101101

102102
async def show_overlay(self, html: str, duration: float = None) -> DisposableStub:
103-
result = await self._page._channel.send(
103+
overlay_id = await self._page._channel.send(
104104
"screencastShowOverlay",
105105
None,
106106
{"html": html, "duration": duration},
@@ -110,7 +110,7 @@ async def show_overlay(self, html: str, duration: float = None) -> DisposableStu
110110
lambda: self._page._channel.send(
111111
"screencastRemoveOverlay",
112112
None,
113-
{"id": result["id"]},
113+
{"id": overlay_id},
114114
)
115115
)
116116

playwright/async_api/_generated.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21598,9 +21598,7 @@ async def start(
2159821598
*,
2159921599
path: typing.Optional[typing.Union[pathlib.Path, str]] = None,
2160021600
quality: typing.Optional[int] = None,
21601-
on_frame: typing.Optional[
21602-
typing.Callable[[ScreencastFrame], typing.Awaitable[typing.Any]]
21603-
] = None,
21601+
on_frame: typing.Optional[typing.Callable[[ScreencastFrame], None]] = None,
2160421602
) -> "AsyncContextManager":
2160521603
"""Screencast.start
2160621604

@@ -21615,7 +21613,7 @@ async def start(
2161521613
Path where the video should be saved when the screencast is stopped. When provided, video recording is started.
2161621614
quality : Union[int, None]
2161721615
The quality of the image, between 0-100.
21618-
on_frame : Union[Callable[[{data: bytes}], typing.Awaitable[typing.Any]], None]
21616+
on_frame : Union[Callable[[{data: bytes}], None], None]
2161921617
Callback that receives JPEG-encoded frame data.
2162021618

2162121619
Returns

playwright/sync_api/_generated.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21688,9 +21688,7 @@ def start(
2168821688
*,
2168921689
path: typing.Optional[typing.Union[pathlib.Path, str]] = None,
2169021690
quality: typing.Optional[int] = None,
21691-
on_frame: typing.Optional[
21692-
typing.Callable[[ScreencastFrame], typing.Awaitable[typing.Any]]
21693-
] = None,
21691+
on_frame: typing.Optional[typing.Callable[[ScreencastFrame], None]] = None,
2169421692
) -> "SyncContextManager":
2169521693
"""Screencast.start
2169621694

@@ -21705,7 +21703,7 @@ def start(
2170521703
Path where the video should be saved when the screencast is stopped. When provided, video recording is started.
2170621704
quality : Union[int, None]
2170721705
The quality of the image, between 0-100.
21708-
on_frame : Union[Callable[[{data: bytes}], typing.Awaitable[typing.Any]], None]
21706+
on_frame : Union[Callable[[{data: bytes}], None], None]
2170921707
Callback that receives JPEG-encoded frame data.
2171021708

2171121709
Returns

scripts/expected_api_mismatch.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ Parameter type mismatch in BrowserContext.route_web_socket(handler=): documented
2020
Parameter type mismatch in Page.route_web_socket(handler=): documented as Callable[[WebSocketRoute], Union[Any, Any]], code has Callable[[WebSocketRoute], Any]
2121
Parameter type mismatch in WebSocketRoute.on_close(handler=): documented as Callable[[Union[int, undefined]], Union[Any, Any]], code has Callable[[Union[int, None], Union[str, None]], Any]
2222
Parameter type mismatch in WebSocketRoute.on_message(handler=): documented as Callable[[str], Union[Any, Any]], code has Callable[[Union[bytes, str]], Any]
23+
24+
# Python on_frame callback is sync, not async
25+
Parameter type mismatch in Screencast.start(on_frame=): documented as Union[Callable[[{data: bytes}], typing.Awaitable[typing.Any]], None], code has Union[Callable[[{data: bytes}], None], None]

tests/async/test_debugger.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright (c) Microsoft Corporation.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import asyncio
16+
17+
from playwright.async_api import BrowserContext, Page
18+
19+
20+
async def test_should_return_none_paused_details_initially(
21+
context: BrowserContext,
22+
) -> None:
23+
dbg = context.debugger
24+
assert dbg.paused_details is None
25+
26+
27+
async def test_should_pause_at_next_and_resume(
28+
page: Page, context: BrowserContext
29+
) -> None:
30+
await page.set_content("<div>click me</div>")
31+
dbg = context.debugger
32+
assert dbg.paused_details is None
33+
34+
await dbg.request_pause()
35+
36+
paused_event = asyncio.Event()
37+
38+
def on_paused_state_changed() -> None:
39+
if dbg.paused_details is not None:
40+
assert "Click" in dbg.paused_details["title"]
41+
paused_event.set()
42+
asyncio.ensure_future(dbg.resume())
43+
44+
dbg.on("pausedstatechanged", on_paused_state_changed)
45+
46+
click_task = asyncio.ensure_future(page.click("div"))
47+
await asyncio.wait_for(paused_event.wait(), timeout=10)
48+
await asyncio.wait_for(click_task, timeout=10)
49+
assert dbg.paused_details is None
50+
51+
52+
async def test_should_step_with_next(page: Page, context: BrowserContext) -> None:
53+
await page.set_content("<div>click me</div>")
54+
dbg = context.debugger
55+
assert dbg.paused_details is None
56+
57+
await dbg.request_pause()
58+
59+
first_pause_seen = [False]
60+
61+
def on_paused_state_changed() -> None:
62+
if dbg.paused_details is not None and not first_pause_seen[0]:
63+
assert "Click" in dbg.paused_details["title"]
64+
first_pause_seen[0] = True
65+
asyncio.ensure_future(dbg.next())
66+
elif dbg.paused_details is not None and first_pause_seen[0]:
67+
asyncio.ensure_future(dbg.resume())
68+
69+
dbg.on("pausedstatechanged", on_paused_state_changed)
70+
71+
click_task = asyncio.ensure_future(page.click("div"))
72+
await asyncio.wait_for(click_task, timeout=10)
73+
assert dbg.paused_details is None
74+
75+
76+
async def test_should_pause_at_pause_call(page: Page, context: BrowserContext) -> None:
77+
await page.set_content("<div>click me</div>")
78+
dbg = context.debugger
79+
assert dbg.paused_details is None
80+
81+
await dbg.request_pause()
82+
83+
paused_event = asyncio.Event()
84+
85+
def on_paused_state_changed() -> None:
86+
if dbg.paused_details is not None:
87+
assert "Pause" in dbg.paused_details["title"]
88+
paused_event.set()
89+
asyncio.ensure_future(dbg.resume())
90+
91+
dbg.on("pausedstatechanged", on_paused_state_changed)
92+
93+
pause_task = asyncio.ensure_future(page.pause())
94+
await asyncio.wait_for(paused_event.wait(), timeout=10)
95+
await asyncio.wait_for(pause_task, timeout=10)
96+
assert dbg.paused_details is None

tests/async/test_screencast.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Copyright (c) Microsoft Corporation.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from pathlib import Path
16+
from typing import List
17+
18+
import pytest
19+
20+
from playwright._impl._api_structures import ScreencastFrame
21+
from playwright.async_api import Page
22+
from tests.server import Server
23+
24+
25+
def _noop_frame(f: ScreencastFrame) -> None:
26+
pass
27+
28+
29+
async def test_screencast_start_should_deliver_frames_via_on_frame(
30+
page: Page, server: Server
31+
) -> None:
32+
frames: List[ScreencastFrame] = []
33+
34+
def collect_frame(f: ScreencastFrame) -> None:
35+
frames.append(f)
36+
37+
await page.screencast.start(on_frame=collect_frame)
38+
await page.goto(server.EMPTY_PAGE)
39+
await page.evaluate("() => document.body.style.backgroundColor = 'red'")
40+
await page.wait_for_timeout(500)
41+
await page.screencast.stop()
42+
assert len(frames) > 0, "expected at least one frame"
43+
for frame in frames:
44+
assert frame["data"][:2] == b"\xff\xd8", "expected JPEG frame"
45+
46+
47+
async def test_screencast_start_should_throw_if_already_started(
48+
page: Page,
49+
) -> None:
50+
await page.screencast.start(on_frame=_noop_frame)
51+
with pytest.raises(Exception, match="already started"):
52+
await page.screencast.start(on_frame=_noop_frame)
53+
await page.screencast.stop()
54+
55+
56+
async def test_screencast_start_should_record_video_to_path(
57+
page: Page, server: Server, tmp_path: Path
58+
) -> None:
59+
video_path = tmp_path / "video.webm"
60+
await page.screencast.start(path=video_path)
61+
await page.goto(server.EMPTY_PAGE)
62+
await page.evaluate("() => document.body.style.backgroundColor = 'red'")
63+
await page.wait_for_timeout(500)
64+
await page.screencast.stop()
65+
assert video_path.exists(), f"video file should exist: {video_path}"
66+
assert video_path.stat().st_size > 0
67+
68+
69+
async def test_screencast_start_returns_disposable(page: Page) -> None:
70+
disposable = await page.screencast.start(on_frame=_noop_frame)
71+
async with disposable:
72+
pass
73+
# After dispose, starting again should succeed.
74+
await page.screencast.start(on_frame=_noop_frame)
75+
await page.screencast.stop()
76+
77+
78+
async def test_screencast_show_overlay(page: Page, server: Server) -> None:
79+
await page.goto(server.EMPTY_PAGE)
80+
disposable = await page.screencast.show_overlay("<div>Hello Overlay</div>")
81+
assert disposable
82+
async with disposable:
83+
pass
84+
85+
86+
async def test_screencast_show_chapter(page: Page, server: Server) -> None:
87+
await page.goto(server.EMPTY_PAGE)
88+
await page.screencast.show_chapter("Chapter Title")
89+
await page.screencast.show_chapter(
90+
"With Description", description="Some details", duration=100
91+
)
92+
93+
94+
async def test_screencast_hide_show_overlays(page: Page, server: Server) -> None:
95+
await page.goto(server.EMPTY_PAGE)
96+
await page.screencast.show_overlay("<div>visible</div>")
97+
await page.screencast.hide_overlays()
98+
await page.screencast.show_overlays()
99+
100+
101+
async def test_screencast_show_and_hide_actions(page: Page, server: Server) -> None:
102+
await page.goto(server.EMPTY_PAGE)
103+
async with await page.screencast.show_actions():
104+
pass
105+
await page.screencast.hide_actions()

0 commit comments

Comments
 (0)