patchright-cli 0.11.0__tar.gz → 0.11.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/.claude-plugin/marketplace.json +1 -1
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/PKG-INFO +1 -1
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/docs/daemon-protocol.md +29 -8
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/pyproject.toml +1 -1
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/__init__.py +1 -1
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/daemon.py +197 -51
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/mcp_server.py +23 -4
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/snapshot.py +13 -1
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_attach.py +1 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_cli_audit.py +8 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_daemon_audit.py +151 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_daemon_handlers.py +4 -3
- patchright_cli-0.11.1/tests/test_external_close.py +338 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_mcp.py +33 -1
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_tabs.py +5 -2
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/uv.lock +2 -2
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/.claude-plugin/skills/browser-automation.json +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/.github/dependabot.yml +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/.github/workflows/ci.yml +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/.github/workflows/publish-docker.yml +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/.github/workflows/publish.yml +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/.gitignore +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/.pre-commit-config.yaml +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/CLAUDE.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/CONTRIBUTING.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/Dockerfile +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/GLOSSARY.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/LICENSE +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/Makefile +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/README.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/compose.yml +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/docs/installation.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/docs/mcp-architecture.svg +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/patchright-cli +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/patchright-cli.cmd +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/gen_skill.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/classify.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/fixtures/common.js +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/fixtures/controls.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/fixtures/delayed.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/fixtures/dialog.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/fixtures/iframe.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/fixtures/iframe_child.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/fixtures/large.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/fixtures/nav.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/fixtures/nav2.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/fixtures/nested.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/fixtures/offscreen.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/fixtures/shadow.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/results/after.json +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/results/before.json +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/results/diff-report.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/results/diff.txt +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/scripts/ref_sweep/sweep.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/skills/patchright-cli/SKILL.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/skills/patchright-cli/references/request-mocking.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/skills/patchright-cli/references/running-code.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/skills/patchright-cli/references/session-management.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/skills/patchright-cli/references/snapshot-refs.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/skills/patchright-cli/references/storage-state.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/skills/patchright-cli/references/tracing.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/skills/patchright-cli/references/video-recording.md +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/__main__.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/_entry.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/agent_hooks.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/cli.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/daemon_pid.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/dashboard.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/debugger.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/snapshot_text.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/toon.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/src/patchright_cli/values.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/__init__.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/detect_fixture.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/fixture.html +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/ref_helpers.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_agent_hooks.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_annotate.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_cli_axi.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_cli_config.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_cli_home.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_daemon_axi.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_daemon_info.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_daemon_pid.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_daemon_read.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_daemon_token.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_debugger.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_docker.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_e2e.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_emulation.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_i41_extras.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_navigation_policy.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_network_guard.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_packaging.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_passkeys.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_pick_locator.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_profiles.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_skill_sync.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_snapshot_refs.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_snapshot_text.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_supply_chain.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_toon.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/test_webmcp.py +0 -0
- {patchright_cli-0.11.0 → patchright_cli-0.11.1}/tests/webmcp_fixture.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: patchright-cli
|
|
3
|
-
Version: 0.11.
|
|
3
|
+
Version: 0.11.1
|
|
4
4
|
Summary: Anti-detect browser automation CLI using Patchright (undetected Playwright fork)
|
|
5
5
|
Project-URL: Homepage, https://github.com/AhaiMk01/patchright-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/AhaiMk01/patchright-cli
|
|
@@ -72,9 +72,29 @@ Every reply carries an explicit boolean `success` and a string `output`, on
|
|
|
72
72
|
both success and failure. `success: false` indicates authentication, command,
|
|
73
73
|
or daemon errors. `output` contains the result or a human-readable error.
|
|
74
74
|
Additional fields, such as `snapshot_path`, are command-specific. Clients must
|
|
75
|
-
ignore unknown fields.
|
|
75
|
+
ignore unknown fields.
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
### Error codes
|
|
78
|
+
|
|
79
|
+
A failed reply in one of these states carries a string `code`. Branch on it
|
|
80
|
+
rather than on `output`, whose wording can change in any release:
|
|
81
|
+
|
|
82
|
+
| `code` | State |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `session_not_open` | The requested session is absent. |
|
|
85
|
+
| `tab_not_open` | The requested named tab is absent from the session, including one whose page closed or crashed outside the daemon. `open --tab <name>` opens it again. |
|
|
86
|
+
| `target_closed` | The page, context or browser closed under the command. Also the default tab's page closed or crashed outside the daemon (a person, the page, the Host, or a renderer crash): the default tab does not move to another page on its own, so pick one with `tab-select <index>` or open one with `tab-new`. An `open` or `attach` that fails while starting a new session never carries it. A command whose own action closes its page (a popup's "Done" button) succeeds and says the page closed. |
|
|
87
|
+
| `no_page` | The session is open but has no pages left, whether they were closed with `tab-close`, closed outside the daemon, or crashed. The session stays open; `tab-new` opens a page. |
|
|
88
|
+
|
|
89
|
+
A page closed or crashed outside the daemon leaves the session as soon as Chrome reports it: `tab-list` stops listing it. A crashed page cannot be reloaded, since Patchright fails every later call on it, so the session closes it (in a Host Context, only a page the session opened). While the default tab has no page, `tab-close <index>` still works; `tab-close` without an index is refused rather than closing a neighbour.
|
|
90
|
+
|
|
91
|
+
Other failures carry no `code`; more codes can be added later, so treat an
|
|
92
|
+
unknown one like a missing one. Successful replies, including the no-ops below,
|
|
93
|
+
never carry `code`. Daemons before 0.11.1 send no codes under the same
|
|
94
|
+
`protocol`, so a client that may meet one keeps the output fragments below as
|
|
95
|
+
its fallback.
|
|
96
|
+
|
|
97
|
+
Clients can also detect these states in failed replies by matching these output
|
|
78
98
|
fragments. Surrounding context and recovery advice can vary. The fragments in
|
|
79
99
|
this table are part of the protocol:
|
|
80
100
|
|
|
@@ -130,17 +150,18 @@ An empty list is `[]`, and its `output` states the zero explicitly, for example
|
|
|
130
150
|
`0 cookies in this session`. Commands not listed here send no `data`. More
|
|
131
151
|
fields or commands can gain `data` later, so read only the keys you need.
|
|
132
152
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
containing `has been closed
|
|
136
|
-
|
|
153
|
+
A command on a page, context or browser that has closed fails with
|
|
154
|
+
`code: "target_closed"`. Its `output` is Playwright's own message, usually
|
|
155
|
+
containing `has been closed`; that text can change with Playwright's version
|
|
156
|
+
without a protocol increment, so match the code instead.
|
|
137
157
|
|
|
138
158
|
## Compatibility
|
|
139
159
|
|
|
140
160
|
`protocol` is independent of the package version. Any incompatible change to
|
|
141
161
|
framing, request limits, request fields (`command`, `args`, `options`, `cwd`,
|
|
142
|
-
`token`), reply fields (`success`, `output`), authentication,
|
|
143
|
-
state-detection fragments in the
|
|
162
|
+
`token`), reply fields (`success`, `output`), authentication, the error
|
|
163
|
+
codes, or the state-detection fragments in the tables above requires a
|
|
164
|
+
protocol increment. Adding an error code does not.
|
|
144
165
|
Raising a request limit is not incompatible. Optional additive fields and new
|
|
145
166
|
commands do not require an increment. Clients can reject unsupported protocol
|
|
146
167
|
versions before sending requests.
|
|
@@ -30,7 +30,7 @@ from urllib.parse import unquote, urlparse
|
|
|
30
30
|
|
|
31
31
|
from patchright_cli import daemon_pid, snapshot_text, values
|
|
32
32
|
from patchright_cli.debugger import SessionDebugger
|
|
33
|
-
from patchright_cli.snapshot import EMPTY_SNAPSHOT, ai_snapshot, probe_ref, save_snapshot
|
|
33
|
+
from patchright_cli.snapshot import EMPTY_SNAPSHOT, ai_snapshot, is_target_closed, probe_ref, save_snapshot
|
|
34
34
|
from patchright_cli.snapshot_text import SnapshotIndex, has_ref, normalize_ref, render_hits
|
|
35
35
|
|
|
36
36
|
logger = logging.getLogger("patchright-cli.daemon")
|
|
@@ -106,6 +106,10 @@ class Tab:
|
|
|
106
106
|
self.history: list[str] = []
|
|
107
107
|
self.history_index: int = -1
|
|
108
108
|
self.codegen: list[str] | None = None
|
|
109
|
+
# Its page closed or crashed outside the daemon. The tab stays until the
|
|
110
|
+
# next command reports it as not open, so a command still running on it
|
|
111
|
+
# never falls back to the default tab's page.
|
|
112
|
+
self.gone = False
|
|
109
113
|
|
|
110
114
|
|
|
111
115
|
class Session:
|
|
@@ -147,6 +151,16 @@ class Session:
|
|
|
147
151
|
# True when `open --webmcp` launched Chrome with the WebMCP feature on.
|
|
148
152
|
self.webmcp = False
|
|
149
153
|
self.current_tab: int = 0
|
|
154
|
+
# The default tab's page closed or crashed outside the daemon. Its index
|
|
155
|
+
# would otherwise slide onto a neighbour, and the next command would run
|
|
156
|
+
# on someone else's tab. Cleared only by select_page.
|
|
157
|
+
self._current_gone = False
|
|
158
|
+
# Named tabs being opened right now. Their pages arrive through the
|
|
159
|
+
# context's page event like any other; they must not become the
|
|
160
|
+
# default tab's page.
|
|
161
|
+
self._opening_named = 0
|
|
162
|
+
# A renderer crash leaves page.is_closed() False, so crashes are remembered here.
|
|
163
|
+
self._crashed: weakref.WeakSet = weakref.WeakSet()
|
|
150
164
|
self.tabs: dict[str, Tab] = {DEFAULT_TAB: Tab(DEFAULT_TAB)}
|
|
151
165
|
# Whether anyone actually works in the implicit default tab. A session
|
|
152
166
|
# opened only through `--tab` never touches it, and an untouched default
|
|
@@ -207,10 +221,35 @@ class Session:
|
|
|
207
221
|
async def setup_listeners(self):
|
|
208
222
|
"""Attach network and dialog listeners to all existing pages."""
|
|
209
223
|
for page in self.pages:
|
|
224
|
+
self._watch_page(page)
|
|
210
225
|
await self._attach_page_listeners(page)
|
|
211
226
|
self.context.on("page", lambda page: asyncio.ensure_future(self._on_new_page(page)))
|
|
212
227
|
|
|
228
|
+
def _watch_page(self, page) -> None:
|
|
229
|
+
# Chrome reports pages a person, the page or the Host closed. A crashed
|
|
230
|
+
# page is gone too: Patchright fails every later call on it, reload
|
|
231
|
+
# included, so it is closed when the session may close it.
|
|
232
|
+
page.on("close", lambda *_: self._forget_page(page, outside=True))
|
|
233
|
+
page.on("crash", lambda *_: self._on_crash(page))
|
|
234
|
+
|
|
235
|
+
def _is_dead(self, page) -> bool:
|
|
236
|
+
return page.is_closed() or page in self._crashed
|
|
237
|
+
|
|
238
|
+
def _on_crash(self, page) -> None:
|
|
239
|
+
self._crashed.add(page)
|
|
240
|
+
self._forget_page(page, outside=True)
|
|
241
|
+
if self.may_close_page(page):
|
|
242
|
+
asyncio.ensure_future(self._close_quietly(page))
|
|
243
|
+
|
|
244
|
+
async def _close_quietly(self, page) -> None:
|
|
245
|
+
with contextlib.suppress(Exception):
|
|
246
|
+
await self._unattended(page.close())
|
|
247
|
+
|
|
213
248
|
async def _on_new_page(self, page):
|
|
249
|
+
# Before any await: a popup can close (or crash) while this runs.
|
|
250
|
+
self._watch_page(page)
|
|
251
|
+
if self._is_dead(page):
|
|
252
|
+
return
|
|
214
253
|
if self.uses_host_context and page not in self.own_pages:
|
|
215
254
|
try:
|
|
216
255
|
opener = await page.opener()
|
|
@@ -218,9 +257,19 @@ class Session:
|
|
|
218
257
|
opener = None
|
|
219
258
|
if opener is not None and opener in self.own_pages:
|
|
220
259
|
self.own_pages.add(page)
|
|
260
|
+
if self._is_dead(page):
|
|
261
|
+
if page in self._crashed and self.may_close_page(page):
|
|
262
|
+
# Ownership is only known now, after the crash handler ran.
|
|
263
|
+
asyncio.ensure_future(self._close_quietly(page))
|
|
264
|
+
return
|
|
221
265
|
if page not in self.pages:
|
|
222
|
-
|
|
223
|
-
|
|
266
|
+
# A named tab's page belongs to that tab. While the default tab's
|
|
267
|
+
# page is gone, a new page came from some other tab. Taking either
|
|
268
|
+
# would put the default caller on someone else's tab.
|
|
269
|
+
if self._current_gone or self._opening_named:
|
|
270
|
+
self.pages.append(page)
|
|
271
|
+
else:
|
|
272
|
+
self.select_page(page)
|
|
224
273
|
# Emulate before the listener setup awaits, so a popup's own scripts
|
|
225
274
|
# see the session's media features as early as possible.
|
|
226
275
|
await self.apply_emulation(page)
|
|
@@ -392,7 +441,7 @@ class Session:
|
|
|
392
441
|
pinned = self.active_tab.page
|
|
393
442
|
if pinned is not None:
|
|
394
443
|
return pinned
|
|
395
|
-
if not self.pages:
|
|
444
|
+
if self._current_gone or not self.pages:
|
|
396
445
|
return None
|
|
397
446
|
idx = max(0, min(self.current_tab, len(self.pages) - 1))
|
|
398
447
|
return self.pages[idx]
|
|
@@ -448,14 +497,14 @@ class Session:
|
|
|
448
497
|
whoever activated last owned every post-await write, so a command that
|
|
449
498
|
suspended came back storing its refs and history on someone else's tab.
|
|
450
499
|
|
|
451
|
-
The binding carries the
|
|
452
|
-
against
|
|
500
|
+
The binding carries the Session itself, so a tab cannot be honoured
|
|
501
|
+
against another session, a reopened one of the same name included. It holds
|
|
502
|
+
the Tab itself, not its name: when the tab is dropped or its name reused
|
|
503
|
+
mid-command, the command keeps working on the tab it started on.
|
|
453
504
|
"""
|
|
454
505
|
bound = _ACTIVE_TAB.get()
|
|
455
|
-
if bound is not None and bound[0]
|
|
456
|
-
|
|
457
|
-
if tab is not None:
|
|
458
|
-
return tab
|
|
506
|
+
if bound is not None and bound[0] is self:
|
|
507
|
+
return bound[1]
|
|
459
508
|
return self._ensure_default_tab()
|
|
460
509
|
|
|
461
510
|
def _ensure_default_tab(self) -> Tab:
|
|
@@ -469,23 +518,26 @@ class Session:
|
|
|
469
518
|
def activate_tab(self, name: str) -> Tab:
|
|
470
519
|
"""Route this task's state access to `name`. Raises KeyError if unknown."""
|
|
471
520
|
tab = self.tabs[name]
|
|
472
|
-
_ACTIVE_TAB.set((self
|
|
521
|
+
_ACTIVE_TAB.set((self, tab))
|
|
473
522
|
return tab
|
|
474
523
|
|
|
475
524
|
async def open_tab(self, name: str) -> Tab:
|
|
476
525
|
"""Return the named tab, giving it a fresh page on first use."""
|
|
477
526
|
existing = self.tabs.get(name)
|
|
478
|
-
if existing is not None:
|
|
527
|
+
if existing is not None and not existing.gone:
|
|
479
528
|
return existing
|
|
480
|
-
#
|
|
481
|
-
#
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
self.
|
|
487
|
-
self.current_tab = min(previous, max(0, len(self.pages) - 1))
|
|
529
|
+
# Leaves the default tab's page alone: whatever it does meanwhile (a
|
|
530
|
+
# close outside, a tab-select) stands.
|
|
531
|
+
self._opening_named += 1
|
|
532
|
+
try:
|
|
533
|
+
page = await self.new_page()
|
|
534
|
+
finally:
|
|
535
|
+
self._opening_named -= 1
|
|
488
536
|
tab = Tab(name, page)
|
|
537
|
+
if self._is_dead(page):
|
|
538
|
+
tab.gone = True
|
|
539
|
+
elif page not in self.pages:
|
|
540
|
+
self.pages.append(page)
|
|
489
541
|
self.tabs[name] = tab
|
|
490
542
|
return tab
|
|
491
543
|
|
|
@@ -503,16 +555,7 @@ class Session:
|
|
|
503
555
|
tab = self.tabs.pop(name)
|
|
504
556
|
page = tab.page
|
|
505
557
|
if page is not None:
|
|
506
|
-
self.
|
|
507
|
-
if page in self.pages:
|
|
508
|
-
removed_index = self.pages.index(page)
|
|
509
|
-
self.pages.remove(page)
|
|
510
|
-
# Follow the page current_tab pointed at. Clamping alone silently
|
|
511
|
-
# reassigns the default caller to its neighbour when an earlier
|
|
512
|
-
# page is the one that went.
|
|
513
|
-
if removed_index < self.current_tab:
|
|
514
|
-
self.current_tab -= 1
|
|
515
|
-
self.current_tab = max(0, min(self.current_tab, len(self.pages) - 1))
|
|
558
|
+
self._forget_page(page)
|
|
516
559
|
try:
|
|
517
560
|
await self._unattended(page.close())
|
|
518
561
|
except Exception:
|
|
@@ -523,13 +566,51 @@ class Session:
|
|
|
523
566
|
self.default_in_use = False
|
|
524
567
|
self._ensure_default_tab()
|
|
525
568
|
|
|
526
|
-
named = [key for key in self.tabs if key != DEFAULT_TAB]
|
|
569
|
+
named = [key for key, t in self.tabs.items() if key != DEFAULT_TAB and not t.gone]
|
|
527
570
|
return bool(named) or self.default_in_use
|
|
528
571
|
|
|
572
|
+
def select_page(self, page) -> None:
|
|
573
|
+
"""Make `page` the default tab's page, tracking it first if it is new."""
|
|
574
|
+
if self._is_dead(page):
|
|
575
|
+
return
|
|
576
|
+
if page not in self.pages:
|
|
577
|
+
self.pages.append(page)
|
|
578
|
+
self.current_tab = self.pages.index(page)
|
|
579
|
+
self._current_gone = False
|
|
580
|
+
|
|
581
|
+
def _forget_page(self, page, *, outside: bool = False) -> None:
|
|
582
|
+
"""Stop tracking `page`. Every removal goes through here; idempotent.
|
|
583
|
+
|
|
584
|
+
`outside` means Chrome reported it closed or crashed, not that a command
|
|
585
|
+
closed it: then the default tab does not move on to another page, and a
|
|
586
|
+
named tab on it is marked gone.
|
|
587
|
+
"""
|
|
588
|
+
self._console_sessions.pop(id(page), None)
|
|
589
|
+
if outside:
|
|
590
|
+
for tab in self.tabs.values():
|
|
591
|
+
if tab.page is page:
|
|
592
|
+
tab.gone = True
|
|
593
|
+
if page not in self.pages:
|
|
594
|
+
return
|
|
595
|
+
removed_index = self.pages.index(page)
|
|
596
|
+
self.pages.remove(page)
|
|
597
|
+
if outside and removed_index == self.current_tab:
|
|
598
|
+
self._current_gone = True
|
|
599
|
+
# Follow the page current_tab pointed at. Clamping alone silently
|
|
600
|
+
# reassigns the default caller to its neighbour when an earlier page
|
|
601
|
+
# is the one that went.
|
|
602
|
+
elif removed_index < self.current_tab:
|
|
603
|
+
self.current_tab -= 1
|
|
604
|
+
self.current_tab = max(0, min(self.current_tab, len(self.pages) - 1))
|
|
605
|
+
|
|
529
606
|
async def new_page(self):
|
|
530
607
|
"""Open a Page in this Session's context and record it as ours."""
|
|
531
608
|
page = await self.context.new_page()
|
|
532
609
|
self.own_pages.add(page)
|
|
610
|
+
if page in self._crashed:
|
|
611
|
+
# It crashed before it was ours, so the crash handler could not close it.
|
|
612
|
+
await self._close_quietly(page)
|
|
613
|
+
return page
|
|
533
614
|
# The context's "page" handler applies this too, but runs as a task;
|
|
534
615
|
# applying here means the caller never navigates an unemulated page.
|
|
535
616
|
await self.apply_emulation(page)
|
|
@@ -1112,10 +1193,16 @@ class DaemonState:
|
|
|
1112
1193
|
return time.monotonic() - self.last_activity > self.idle_timeout
|
|
1113
1194
|
|
|
1114
1195
|
async def get_or_create_session(self, name: str = "default", **kwargs) -> Session:
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1196
|
+
try:
|
|
1197
|
+
if kwargs.get("cdp_endpoint") and kwargs.get("context_mode") == "host":
|
|
1198
|
+
async with self._host_attach_lock:
|
|
1199
|
+
return await self._get_or_create_session(name, **kwargs)
|
|
1200
|
+
return await self._get_or_create_session(name, **kwargs)
|
|
1201
|
+
except Exception as exc:
|
|
1202
|
+
# A session that never got registered was never open, so there is no target to recover.
|
|
1203
|
+
if is_target_closed(exc) and name not in self.sessions:
|
|
1204
|
+
raise RuntimeError(f"Chrome closed while opening session '{name}': {exc}") from exc
|
|
1205
|
+
raise
|
|
1119
1206
|
|
|
1120
1207
|
async def _get_or_create_session(
|
|
1121
1208
|
self,
|
|
@@ -1515,6 +1602,8 @@ async def _page_info(session: Session, cwd: str | None = None) -> dict:
|
|
|
1515
1602
|
"""Return standard page info + snapshot after a state-changing command."""
|
|
1516
1603
|
page = session.page
|
|
1517
1604
|
if page is None:
|
|
1605
|
+
if session.pages:
|
|
1606
|
+
return {"success": True, "output": "### Page\n- The page closed after the command."}
|
|
1518
1607
|
return {"success": True, "output": "No page open."}
|
|
1519
1608
|
|
|
1520
1609
|
url = page.url
|
|
@@ -1523,7 +1612,14 @@ async def _page_info(session: Session, cwd: str | None = None) -> dict:
|
|
|
1523
1612
|
except Exception:
|
|
1524
1613
|
title = ""
|
|
1525
1614
|
|
|
1526
|
-
|
|
1615
|
+
try:
|
|
1616
|
+
snap = await _ai_snapshot(session, page)
|
|
1617
|
+
except Exception as exc:
|
|
1618
|
+
if not is_target_closed(exc):
|
|
1619
|
+
raise
|
|
1620
|
+
# The command's own action closed the page (a popup's "Done" button). It ran, so this is no failure.
|
|
1621
|
+
return {"success": True, "output": f"### Page\n- Page URL: {url}\n- The page closed after the command."}
|
|
1622
|
+
snap_path = save_snapshot(_printed(snap), cwd)
|
|
1527
1623
|
|
|
1528
1624
|
output_lines = [
|
|
1529
1625
|
"### Page",
|
|
@@ -1791,9 +1887,20 @@ def _error_message(cmd: str, args: list, options: dict, exc: Exception) -> tuple
|
|
|
1791
1887
|
return message, []
|
|
1792
1888
|
|
|
1793
1889
|
|
|
1890
|
+
# Stable `code` values on failed replies (docs/daemon-protocol.md). Clients branch
|
|
1891
|
+
# on these instead of on `output`, whose wording can change in any release.
|
|
1892
|
+
SESSION_NOT_OPEN = "session_not_open"
|
|
1893
|
+
TAB_NOT_OPEN = "tab_not_open"
|
|
1894
|
+
TARGET_CLOSED = "target_closed"
|
|
1895
|
+
NO_PAGE = "no_page"
|
|
1896
|
+
|
|
1897
|
+
|
|
1794
1898
|
def _error_reply(cmd: str, args: list, options: dict, exc: Exception, *extra: str) -> dict:
|
|
1795
1899
|
message, helps = _error_message(cmd, args, options, exc)
|
|
1796
|
-
|
|
1900
|
+
reply = {"success": False, "output": "\n".join([f"Error: {message}", *extra, *helps])}
|
|
1901
|
+
if is_target_closed(exc):
|
|
1902
|
+
reply["code"] = TARGET_CLOSED
|
|
1903
|
+
return reply
|
|
1797
1904
|
|
|
1798
1905
|
|
|
1799
1906
|
@register("click")
|
|
@@ -1969,6 +2076,8 @@ async def cmd_snapshot(session: Session, page, args: list, options: dict, cwd: s
|
|
|
1969
2076
|
try:
|
|
1970
2077
|
matches = await scoped.count()
|
|
1971
2078
|
except Exception as exc:
|
|
2079
|
+
if is_target_closed(exc):
|
|
2080
|
+
raise
|
|
1972
2081
|
return {"success": False, "output": f"Invalid selector {selector!r}: {exc}"}
|
|
1973
2082
|
if not matches:
|
|
1974
2083
|
return {"success": False, "output": f"No element matches {selector!r} on this page."}
|
|
@@ -2068,6 +2177,8 @@ async def _find_by_role(session: Session, page, role: str, options: dict, limit:
|
|
|
2068
2177
|
try:
|
|
2069
2178
|
total = await locator.count()
|
|
2070
2179
|
except Exception as exc:
|
|
2180
|
+
if is_target_closed(exc):
|
|
2181
|
+
raise
|
|
2071
2182
|
return {"success": False, "output": f"find {query} failed: {exc}"}
|
|
2072
2183
|
if not total:
|
|
2073
2184
|
return {
|
|
@@ -2499,6 +2610,8 @@ async def cmd_html(session: Session, page, args: list, options: dict, cwd: str |
|
|
|
2499
2610
|
try:
|
|
2500
2611
|
matches = await scoped.count()
|
|
2501
2612
|
except Exception as exc:
|
|
2613
|
+
if is_target_closed(exc):
|
|
2614
|
+
raise
|
|
2502
2615
|
return {"success": False, "output": f"Invalid selector {selector!r}: {exc}"}
|
|
2503
2616
|
if matches != 1:
|
|
2504
2617
|
if not matches:
|
|
@@ -2634,6 +2747,7 @@ async def cmd_tab_new(session: Session, page, args: list, options: dict, cwd: st
|
|
|
2634
2747
|
url = args[0] if args else "about:blank"
|
|
2635
2748
|
await session.check_navigation(url, _web_only(options))
|
|
2636
2749
|
new_page = await session.new_page()
|
|
2750
|
+
session.select_page(new_page)
|
|
2637
2751
|
if url and url != "about:blank":
|
|
2638
2752
|
await session.goto(new_page, url, _web_only(options))
|
|
2639
2753
|
return await _page_info(session, cwd)
|
|
@@ -2641,6 +2755,11 @@ async def cmd_tab_new(session: Session, page, args: list, options: dict, cwd: st
|
|
|
2641
2755
|
|
|
2642
2756
|
@register("tab-close")
|
|
2643
2757
|
async def cmd_tab_close(session: Session, page, args: list, options: dict, cwd: str | None, state: DaemonState) -> dict:
|
|
2758
|
+
if not session.pages:
|
|
2759
|
+
return _no_page_reply()
|
|
2760
|
+
if not args and session.page is None:
|
|
2761
|
+
# current_tab already points at a neighbour; closing it would close someone else's tab.
|
|
2762
|
+
return _gone_reply()
|
|
2644
2763
|
idx = int(args[0]) if args else session.current_tab
|
|
2645
2764
|
if not (0 <= idx < len(session.pages)):
|
|
2646
2765
|
return {"success": False, "output": f"Invalid tab index: {idx}"}
|
|
@@ -2658,11 +2777,7 @@ async def cmd_tab_close(session: Session, page, args: list, options: dict, cwd:
|
|
|
2658
2777
|
await session.close_tab(owner)
|
|
2659
2778
|
return {"success": True, "output": f"Tab {idx} closed (was tab '{owner}')."}
|
|
2660
2779
|
|
|
2661
|
-
session.
|
|
2662
|
-
session._console_sessions.pop(id(page), None)
|
|
2663
|
-
if idx < session.current_tab:
|
|
2664
|
-
session.current_tab -= 1
|
|
2665
|
-
session.current_tab = max(0, min(session.current_tab, len(session.pages) - 1))
|
|
2780
|
+
session._forget_page(page)
|
|
2666
2781
|
await page.close()
|
|
2667
2782
|
return {"success": True, "output": f"Tab {idx} closed."}
|
|
2668
2783
|
|
|
@@ -2683,7 +2798,7 @@ async def cmd_tab_select(
|
|
|
2683
2798
|
}
|
|
2684
2799
|
idx = int(args[0])
|
|
2685
2800
|
if 0 <= idx < len(session.pages):
|
|
2686
|
-
session.
|
|
2801
|
+
session.select_page(session.pages[idx])
|
|
2687
2802
|
await session.pages[idx].bring_to_front()
|
|
2688
2803
|
return await _page_info(session, cwd)
|
|
2689
2804
|
return {"success": False, "output": f"Invalid tab index: {idx}"}
|
|
@@ -4089,6 +4204,8 @@ _WEBMCP_UNTRUSTED = "Tool names, descriptions, schemas and results are page-prov
|
|
|
4089
4204
|
|
|
4090
4205
|
def _webmcp_error(exc: Exception) -> dict:
|
|
4091
4206
|
"""A WebMCP failure as a reply: Playwright's first line, without its `WebMCP.tools: ` prefix."""
|
|
4207
|
+
if is_target_closed(exc):
|
|
4208
|
+
raise exc
|
|
4092
4209
|
message = str(exc).strip().splitlines()[0] if str(exc).strip() else type(exc).__name__
|
|
4093
4210
|
message = re.sub(r"^WebMCP\.\w+: ", "", message)
|
|
4094
4211
|
if "WebMCP is not enabled" in message:
|
|
@@ -4274,6 +4391,30 @@ async def cmd_debugger_status(
|
|
|
4274
4391
|
|
|
4275
4392
|
# Commands that end a Session run its teardown unattended (see SessionDebugger),
|
|
4276
4393
|
# so they must not be held at a pause themselves.
|
|
4394
|
+
def _no_page_reply() -> dict:
|
|
4395
|
+
"""The session is open but has no pages left."""
|
|
4396
|
+
return {
|
|
4397
|
+
"success": False,
|
|
4398
|
+
"output": "No page open. Run 'tab-new' to create one, or 'close' and 'open' again.",
|
|
4399
|
+
"code": NO_PAGE,
|
|
4400
|
+
}
|
|
4401
|
+
|
|
4402
|
+
|
|
4403
|
+
def _gone_reply() -> dict:
|
|
4404
|
+
"""The default tab's page closed or crashed outside the daemon, and no page took its place."""
|
|
4405
|
+
return {
|
|
4406
|
+
"success": False,
|
|
4407
|
+
"output": (
|
|
4408
|
+
"This tab's page closed or crashed outside the daemon.\n"
|
|
4409
|
+
"help: run `tab-list`, then `tab-select <index>`, or `tab-new [url]`"
|
|
4410
|
+
),
|
|
4411
|
+
"code": TARGET_CLOSED,
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4414
|
+
|
|
4415
|
+
# Commands that run without a current page: the tab ones get a page back, and
|
|
4416
|
+
# console reads the session-wide buffer, which outlives the page that closed.
|
|
4417
|
+
_PAGELESS = frozenset({"tab-new", "tab-select", "tab-list", "tab-close", "console"})
|
|
4277
4418
|
_NOT_HELD = {"close", "close-all", "kill-all", "detach", "delete-data", "list", "profile-list"}
|
|
4278
4419
|
|
|
4279
4420
|
|
|
@@ -4368,6 +4509,8 @@ async def _dispatch_command(state: DaemonState, msg: dict) -> dict:
|
|
|
4368
4509
|
session.default_in_use = True
|
|
4369
4510
|
session._ensure_default_tab()
|
|
4370
4511
|
session.activate_tab(tab_name)
|
|
4512
|
+
if session.page is None and session.pages:
|
|
4513
|
+
return _gone_reply()
|
|
4371
4514
|
if session.page:
|
|
4372
4515
|
session.push_history(session.page.url)
|
|
4373
4516
|
return await _page_info(session, cwd)
|
|
@@ -4482,6 +4625,7 @@ async def _dispatch_command(state: DaemonState, msg: dict) -> dict:
|
|
|
4482
4625
|
return {
|
|
4483
4626
|
"success": False,
|
|
4484
4627
|
"output": f"Session '{session_name}' is not open. Run 'open' first.",
|
|
4628
|
+
"code": SESSION_NOT_OPEN,
|
|
4485
4629
|
}
|
|
4486
4630
|
|
|
4487
4631
|
page = session.page
|
|
@@ -4534,6 +4678,8 @@ async def _dispatch_command(state: DaemonState, msg: dict) -> dict:
|
|
|
4534
4678
|
# Route to the caller's tab last, so it cannot shadow an unknown command
|
|
4535
4679
|
# or the page-less commands above, and re-read the page afterwards --
|
|
4536
4680
|
# activation is what decides which page `session.page` resolves to.
|
|
4681
|
+
if tab_name in session.tabs and session.tabs[tab_name].gone is True:
|
|
4682
|
+
del session.tabs[tab_name]
|
|
4537
4683
|
if tab_name not in session.tabs:
|
|
4538
4684
|
return {
|
|
4539
4685
|
"success": False,
|
|
@@ -4541,19 +4687,19 @@ async def _dispatch_command(state: DaemonState, msg: dict) -> dict:
|
|
|
4541
4687
|
f"Tab '{tab_name}' is not open in session '{session_name}'. "
|
|
4542
4688
|
f"Run `patchright-cli --tab {tab_name} open <url>` first."
|
|
4543
4689
|
),
|
|
4690
|
+
"code": TAB_NOT_OPEN,
|
|
4544
4691
|
}
|
|
4545
4692
|
session.activate_tab(tab_name)
|
|
4546
4693
|
page = session.page
|
|
4547
4694
|
|
|
4548
|
-
if page is None:
|
|
4549
|
-
return
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
}
|
|
4553
|
-
|
|
4554
|
-
await _apply_timeouts(page, options)
|
|
4695
|
+
if page is None and session.pages and cmd not in _PAGELESS:
|
|
4696
|
+
return _gone_reply()
|
|
4697
|
+
if page is None and cmd not in _PAGELESS:
|
|
4698
|
+
return _no_page_reply()
|
|
4555
4699
|
|
|
4556
|
-
|
|
4700
|
+
if page is not None:
|
|
4701
|
+
await _apply_timeouts(page, options)
|
|
4702
|
+
await _draw_action_callout(session, page, cmd, args)
|
|
4557
4703
|
result = await handler(session, page, args, options, cwd, state)
|
|
4558
4704
|
if result.get("success") and session._codegen is not None and cmd in _CODEGEN_RECORDABLE:
|
|
4559
4705
|
args_quoted = [f'"{a}"' if " " in a else a for a in args]
|
|
@@ -24,6 +24,8 @@ from fastmcp.tools import ToolResult
|
|
|
24
24
|
from mcp.types import ImageContent
|
|
25
25
|
from pydantic import Field
|
|
26
26
|
|
|
27
|
+
from patchright_cli import values
|
|
28
|
+
|
|
27
29
|
CLI = (sys.executable, "-m", "patchright_cli")
|
|
28
30
|
DAEMON_PORT = int(os.environ.get("PATCHRIGHT_CLI_MCP_DAEMON_PORT", "9321"))
|
|
29
31
|
# File paths given to tools must sit beneath MCP_ROOT, so an MCP client (possibly
|
|
@@ -220,6 +222,7 @@ TOOL_SPECS = {
|
|
|
220
222
|
"description": "CLI command: snapshot Capture an accessibility snapshot and refresh element references.",
|
|
221
223
|
"params": [
|
|
222
224
|
("ref", "string", False, "", "Optional element reference for a subtree."),
|
|
225
|
+
("selector", "string", False, "", "Optional CSS selector of the one element to print; not with ref."),
|
|
223
226
|
("filename", "string", False, "", "Optional output file."),
|
|
224
227
|
("depth", "number", False, 0, "Maximum snapshot depth; zero means unlimited."),
|
|
225
228
|
("interactive", "boolean", False, False, "Return interactive elements only."),
|
|
@@ -286,7 +289,8 @@ TOOL_SPECS = {
|
|
|
286
289
|
],
|
|
287
290
|
},
|
|
288
291
|
"patchright_drop": {
|
|
289
|
-
"description": "CLI command: drop Drop a file or data payload onto an element."
|
|
292
|
+
"description": "CLI command: drop Drop a file or data payload onto an element. Fails when the target does not "
|
|
293
|
+
"accept drops (its dragover handler must call preventDefault()).",
|
|
290
294
|
"params": [
|
|
291
295
|
("ref", "string", True, None, "Target element reference."),
|
|
292
296
|
("path", "string", False, "", "File path."),
|
|
@@ -794,9 +798,16 @@ TOOL_SPECS = {
|
|
|
794
798
|
],
|
|
795
799
|
},
|
|
796
800
|
"patchright_console": {
|
|
797
|
-
"description": "CLI command: console Return captured browser console messages."
|
|
801
|
+
"description": "CLI command: console Return captured browser console messages. The first call starts "
|
|
802
|
+
"session-wide CDP capture, which pages can detect; messages from before it are unavailable.",
|
|
798
803
|
"params": [
|
|
799
|
-
(
|
|
804
|
+
(
|
|
805
|
+
"level",
|
|
806
|
+
"string",
|
|
807
|
+
False,
|
|
808
|
+
None,
|
|
809
|
+
"Level to filter by, e.g. error, warning, info or log; empty for every level.",
|
|
810
|
+
),
|
|
800
811
|
("clear", "boolean", False, False, "Clear messages after reading."),
|
|
801
812
|
("session", "string", False, "default", "Named Patchright browser session."),
|
|
802
813
|
],
|
|
@@ -840,7 +851,8 @@ TOOL_SPECS = {
|
|
|
840
851
|
],
|
|
841
852
|
},
|
|
842
853
|
"patchright_highlight": {
|
|
843
|
-
"description": "CLI command: highlight Show, style or hide a page highlight overlay."
|
|
854
|
+
"description": "CLI command: highlight Show, style or hide a page highlight overlay. The page can detect the "
|
|
855
|
+
"overlay while it is shown; hiding the last highlight removes it.",
|
|
844
856
|
"params": [
|
|
845
857
|
("ref", "string", False, "", "Element reference; optional with hide=true, which then clears all."),
|
|
846
858
|
("style", "string", False, "", "Optional CSS style."),
|
|
@@ -1260,9 +1272,13 @@ def _build_args(name: str, p: dict[str, Any]) -> list[str]:
|
|
|
1260
1272
|
|
|
1261
1273
|
if name == "patchright_snapshot":
|
|
1262
1274
|
_validate_cli_path(p["filename"])
|
|
1275
|
+
if p["ref"] and p["selector"]:
|
|
1276
|
+
raise ValueError("Give either ref or selector, not both")
|
|
1263
1277
|
args = ["snapshot"]
|
|
1264
1278
|
if p["ref"]:
|
|
1265
1279
|
args.append(_V(p["ref"]))
|
|
1280
|
+
if p["selector"]:
|
|
1281
|
+
args.append(f"--selector={p['selector']}")
|
|
1266
1282
|
if p["filename"]:
|
|
1267
1283
|
args.append(f"--filename={p['filename']}")
|
|
1268
1284
|
if p["depth"] != 0:
|
|
@@ -1616,6 +1632,9 @@ def _parameter_type(tool_name: str, param_name: str, type_name: str) -> Any:
|
|
|
1616
1632
|
if tool_name == "patchright_network_state_set" and param_name == "state":
|
|
1617
1633
|
return Literal["online", "offline"]
|
|
1618
1634
|
|
|
1635
|
+
if tool_name == "patchright_console" and param_name == "level":
|
|
1636
|
+
return Literal[("", *values.CONSOLE_LEVELS)] | None
|
|
1637
|
+
|
|
1619
1638
|
if tool_name in _EMULATION_VALUES and param_name == "value":
|
|
1620
1639
|
return Literal[_EMULATION_VALUES[tool_name]]
|
|
1621
1640
|
|
|
@@ -17,6 +17,16 @@ from patchright_cli.snapshot_text import REF_TAG
|
|
|
17
17
|
EMPTY_SNAPSHOT = "# Empty page - no accessible elements found\n"
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def is_target_closed(exc: Exception) -> bool:
|
|
21
|
+
"""The page, context or browser closed under the command.
|
|
22
|
+
|
|
23
|
+
Patchright raises every driver-reported close as TargetClosedError, matched
|
|
24
|
+
by name because Patchright is imported lazily. Never by message: a page's
|
|
25
|
+
own error can say "has been closed".
|
|
26
|
+
"""
|
|
27
|
+
return type(exc).__name__ == "TargetClosedError"
|
|
28
|
+
|
|
29
|
+
|
|
20
30
|
async def ai_snapshot(page, boxes: bool = False) -> str:
|
|
21
31
|
"""The full page's AI snapshot, iframes included. Refreshes the page's ref map.
|
|
22
32
|
|
|
@@ -24,7 +34,9 @@ async def ai_snapshot(page, boxes: bool = False) -> str:
|
|
|
24
34
|
"""
|
|
25
35
|
try:
|
|
26
36
|
return await page.aria_snapshot(mode="ai", boxes=True if boxes else None)
|
|
27
|
-
except Exception:
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
if is_target_closed(exc):
|
|
39
|
+
raise
|
|
28
40
|
return ""
|
|
29
41
|
|
|
30
42
|
|