appium-live-view 0.1.0__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.
- appium_live_view-0.1.0/LICENSE +21 -0
- appium_live_view-0.1.0/PKG-INFO +84 -0
- appium_live_view-0.1.0/README.md +72 -0
- appium_live_view-0.1.0/pyproject.toml +25 -0
- appium_live_view-0.1.0/setup.cfg +4 -0
- appium_live_view-0.1.0/src/appium_live_view/__init__.py +23 -0
- appium_live_view-0.1.0/src/appium_live_view/_assets.py +8 -0
- appium_live_view-0.1.0/src/appium_live_view/_locators.py +65 -0
- appium_live_view-0.1.0/src/appium_live_view/_parse.py +192 -0
- appium_live_view-0.1.0/src/appium_live_view/_render.py +244 -0
- appium_live_view-0.1.0/src/appium_live_view.egg-info/PKG-INFO +84 -0
- appium_live_view-0.1.0/src/appium_live_view.egg-info/SOURCES.txt +13 -0
- appium_live_view-0.1.0/src/appium_live_view.egg-info/dependency_links.txt +1 -0
- appium_live_view-0.1.0/src/appium_live_view.egg-info/top_level.txt +1 -0
- appium_live_view-0.1.0/tests/test_render.py +111 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 v-dermichev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: appium-live-view
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build a standalone, interactive HTML live view from an Appium page source (XML) + screenshot
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/v-dermichev/appium-live-view-plugin
|
|
7
|
+
Keywords: appium,allure,inspector,screenshot,xml,testing
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# appium-live-view (Python)
|
|
14
|
+
|
|
15
|
+
Build a standalone, interactive HTML **live view** — the Appium Inspector
|
|
16
|
+
experience (hover to highlight, click to inspect attributes + locators, a
|
|
17
|
+
selectable source tree, an XPath tester) — from a page source (XML) + screenshot.
|
|
18
|
+
No server, no browser, no Node: one function returns a self-contained HTML string
|
|
19
|
+
you can attach to Allure or save anywhere.
|
|
20
|
+
|
|
21
|
+
It's the Python counterpart of the [Appium plugin / JS renderer](../README.md).
|
|
22
|
+
The interactive CSS + JS are shared verbatim with the JS renderer (generated by
|
|
23
|
+
`tools/extract-assets.mjs`), so both produce the same live view.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install ./python # from this repo
|
|
29
|
+
# or, once published:
|
|
30
|
+
pip install appium-live-view
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from appium_live_view import build_live_view_html
|
|
37
|
+
import allure
|
|
38
|
+
|
|
39
|
+
html = build_live_view_html(
|
|
40
|
+
driver.page_source, # XML string
|
|
41
|
+
driver.get_screenshot_as_png(), # raw PNG bytes (also accepts base64 or a data: URI)
|
|
42
|
+
title="Login screen",
|
|
43
|
+
platform_name=driver.capabilities.get("platformName"),
|
|
44
|
+
)
|
|
45
|
+
allure.attach(html, "Live view", allure.attachment_type.HTML)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
A pytest failure hook is a natural home:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import pytest, allure
|
|
52
|
+
from appium_live_view import build_live_view_html
|
|
53
|
+
|
|
54
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
55
|
+
def pytest_runtest_makereport(item, call):
|
|
56
|
+
outcome = yield
|
|
57
|
+
report = outcome.get_result()
|
|
58
|
+
driver = getattr(item.instance, "driver", None)
|
|
59
|
+
if report.when == "call" and report.failed and driver is not None:
|
|
60
|
+
html = build_live_view_html(driver.page_source, driver.get_screenshot_as_png())
|
|
61
|
+
allure.attach(html, "Live view", allure.attachment_type.HTML)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API
|
|
65
|
+
|
|
66
|
+
`build_live_view_html(xml=None, screenshot=None, *, title=None, platform_name=None, selected_path=None, parsed=None) -> str`
|
|
67
|
+
|
|
68
|
+
- `xml` — Appium page source (`driver.page_source`).
|
|
69
|
+
- `screenshot` — raw PNG `bytes`, a base64 `str`, or a full `data:` URI. Optional
|
|
70
|
+
(overlays still work without it).
|
|
71
|
+
- `selected_path` — dot-separated node path to pre-select (e.g. the element a step
|
|
72
|
+
acted on).
|
|
73
|
+
|
|
74
|
+
Also exported: `parse_source`, `parse_coordinates`, `suggest_locators`,
|
|
75
|
+
`absolute_xpath`.
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- **Interactivity inline in Allure 3:** hover and click-to-pin and the source tree
|
|
80
|
+
are pure CSS and work inline; copy, the XPath tester and the download buttons
|
|
81
|
+
need JavaScript, which Allure strips from inline attachments. Open the attachment
|
|
82
|
+
standalone, or apply the report patch in
|
|
83
|
+
[`../examples/allure-inline-interactive/`](../examples/allure-inline-interactive/).
|
|
84
|
+
- **Requires Python 3.8+**, no third-party dependencies.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# appium-live-view (Python)
|
|
2
|
+
|
|
3
|
+
Build a standalone, interactive HTML **live view** — the Appium Inspector
|
|
4
|
+
experience (hover to highlight, click to inspect attributes + locators, a
|
|
5
|
+
selectable source tree, an XPath tester) — from a page source (XML) + screenshot.
|
|
6
|
+
No server, no browser, no Node: one function returns a self-contained HTML string
|
|
7
|
+
you can attach to Allure or save anywhere.
|
|
8
|
+
|
|
9
|
+
It's the Python counterpart of the [Appium plugin / JS renderer](../README.md).
|
|
10
|
+
The interactive CSS + JS are shared verbatim with the JS renderer (generated by
|
|
11
|
+
`tools/extract-assets.mjs`), so both produce the same live view.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install ./python # from this repo
|
|
17
|
+
# or, once published:
|
|
18
|
+
pip install appium-live-view
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Use
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from appium_live_view import build_live_view_html
|
|
25
|
+
import allure
|
|
26
|
+
|
|
27
|
+
html = build_live_view_html(
|
|
28
|
+
driver.page_source, # XML string
|
|
29
|
+
driver.get_screenshot_as_png(), # raw PNG bytes (also accepts base64 or a data: URI)
|
|
30
|
+
title="Login screen",
|
|
31
|
+
platform_name=driver.capabilities.get("platformName"),
|
|
32
|
+
)
|
|
33
|
+
allure.attach(html, "Live view", allure.attachment_type.HTML)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A pytest failure hook is a natural home:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import pytest, allure
|
|
40
|
+
from appium_live_view import build_live_view_html
|
|
41
|
+
|
|
42
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
43
|
+
def pytest_runtest_makereport(item, call):
|
|
44
|
+
outcome = yield
|
|
45
|
+
report = outcome.get_result()
|
|
46
|
+
driver = getattr(item.instance, "driver", None)
|
|
47
|
+
if report.when == "call" and report.failed and driver is not None:
|
|
48
|
+
html = build_live_view_html(driver.page_source, driver.get_screenshot_as_png())
|
|
49
|
+
allure.attach(html, "Live view", allure.attachment_type.HTML)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
|
|
54
|
+
`build_live_view_html(xml=None, screenshot=None, *, title=None, platform_name=None, selected_path=None, parsed=None) -> str`
|
|
55
|
+
|
|
56
|
+
- `xml` — Appium page source (`driver.page_source`).
|
|
57
|
+
- `screenshot` — raw PNG `bytes`, a base64 `str`, or a full `data:` URI. Optional
|
|
58
|
+
(overlays still work without it).
|
|
59
|
+
- `selected_path` — dot-separated node path to pre-select (e.g. the element a step
|
|
60
|
+
acted on).
|
|
61
|
+
|
|
62
|
+
Also exported: `parse_source`, `parse_coordinates`, `suggest_locators`,
|
|
63
|
+
`absolute_xpath`.
|
|
64
|
+
|
|
65
|
+
## Notes
|
|
66
|
+
|
|
67
|
+
- **Interactivity inline in Allure 3:** hover and click-to-pin and the source tree
|
|
68
|
+
are pure CSS and work inline; copy, the XPath tester and the download buttons
|
|
69
|
+
need JavaScript, which Allure strips from inline attachments. Open the attachment
|
|
70
|
+
standalone, or apply the report patch in
|
|
71
|
+
[`../examples/allure-inline-interactive/`](../examples/allure-inline-interactive/).
|
|
72
|
+
- **Requires Python 3.8+**, no third-party dependencies.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "appium-live-view"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Build a standalone, interactive HTML live view from an Appium page source (XML) + screenshot"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
keywords = ["appium", "allure", "inspector", "screenshot", "xml", "testing"]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/v-dermichev/appium-live-view-plugin"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["src"]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.package-data]
|
|
22
|
+
appium_live_view = ["_assets.py"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""appium-live-view — build a standalone, interactive HTML "live view" (Appium
|
|
2
|
+
Inspector-style: hover to highlight, click to inspect, source tree, XPath tester)
|
|
3
|
+
from a page source (XML) + screenshot, with no server or browser needed.
|
|
4
|
+
|
|
5
|
+
from appium_live_view import build_live_view_html
|
|
6
|
+
|
|
7
|
+
html = build_live_view_html(driver.page_source, driver.get_screenshot_as_png())
|
|
8
|
+
allure.attach(html, "Live view", allure.attachment_type.HTML)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._locators import absolute_xpath, suggest_locators
|
|
12
|
+
from ._parse import parse_coordinates, parse_source
|
|
13
|
+
from ._render import build_live_view_html
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"build_live_view_html",
|
|
17
|
+
"parse_source",
|
|
18
|
+
"parse_coordinates",
|
|
19
|
+
"suggest_locators",
|
|
20
|
+
"absolute_xpath",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Auto-generated by tools/extract-assets.mjs — do not edit by hand.
|
|
2
|
+
# The browser CSS and runtime JS from lib/render.js, shared verbatim by the
|
|
3
|
+
# JS and Python renderers. Re-run `node tools/extract-assets.mjs` after editing
|
|
4
|
+
# the <style> or <script> in lib/render.js.
|
|
5
|
+
|
|
6
|
+
BASE_CSS = "\n:root{color-scheme:light dark}\n*{box-sizing:border-box}\nbody{margin:0;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif;color:#1c1c1e;background:#f5f5f7}\n.lv-r{position:absolute;width:0;height:0;opacity:0;pointer-events:none}\n.lv-head{display:flex;gap:12px;align-items:baseline;padding:10px 14px;border-bottom:1px solid rgba(0,0,0,.1)}\n.lv-head h1{font-size:14px;margin:0;font-weight:600}\n.lv-head .lv-meta{color:#6e6e73;font-size:12px}\n.lv-head .lv-xpath{margin-left:auto;display:flex;align-items:center;gap:8px}\n.lv-head input[type=text]{font:inherit;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;width:min(320px,46vw);padding:4px 8px;border:1px solid rgba(0,0,0,.2);border-radius:6px;background:#fff;color:inherit}\n.lv-xpath-status{font-size:12px;white-space:nowrap;color:#8a8a8e}\n.lv-xpath-status.ok{color:#16a34a}\n.lv-xpath-status.warn{color:#d97706}\n.lv-xpath-status.err{color:#e5484d}\n.lv-head .lv-tools{display:flex;gap:6px}\n.lv-btn{font:inherit;font-size:12px;padding:4px 10px;border:1px solid rgba(0,0,0,.2);border-radius:6px;background:#fff;color:inherit;cursor:pointer;white-space:nowrap;text-decoration:none;display:inline-block}\n.lv-btn:hover{background:#eee}\na.lv-btn:not([href]){opacity:.5;cursor:default}\n.lv-tree{display:none;flex:1 1 300px;min-width:240px;max-height:78vh;overflow:auto;background:#fff;border:1px solid rgba(0,0,0,.1);border-radius:10px}\n#lv-tree-toggle:checked~.lv-main .lv-tree{display:block}\n#lv-tree-toggle:checked~.lv-head .lv-btn-src{background:#e5e5ea;border-color:rgba(0,0,0,.4)}\n.lv-tree-head{position:sticky;top:0;background:#fff;padding:7px 10px;border-bottom:1px solid rgba(0,0,0,.08);font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:#8a8a8e}\n.lv-tree-body{padding:4px 2px}\n.lv-node{display:block;white-space:nowrap;padding:2px 6px;border-radius:4px;cursor:pointer;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:inherit}\n.lv-node:hover{background:rgba(59,130,246,.14)}\n.lv-node.lv-node-hit{background:rgba(168,85,247,.22)}\n.lv-node-tag{font-weight:600}\n.lv-node-attr{color:#8a8a8e}\n.lv-node-nobox{color:#c0392b;opacity:.7}\n.lv-main{display:flex;gap:16px;align-items:flex-start;padding:14px;flex-wrap:wrap}\n.lv-stage{position:relative;flex:0 0 auto;width:min(420px,92vw);aspect-ratio:9 / 16;background:#000;border-radius:10px;overflow:hidden;box-shadow:0 1px 6px rgba(0,0,0,.2)}\n.lv-shot{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:block;user-select:none;-webkit-user-drag:none}\n/* outline-offset:-2px draws highlight borders INSIDE each element box, so\n adjacent/nested element outlines never overlap or bleed onto neighbours. */\n.lv-el{position:absolute;cursor:pointer;outline:1px solid transparent;outline-offset:-2px}\n.lv-el:hover{outline:2px solid #3b82f6;background:rgba(59,130,246,.14)}\n.lv-el.lv-xhit{outline:2px solid #a855f7;background:rgba(168,85,247,.22);z-index:8000}\n.lv-tip{display:none;position:absolute;left:0;bottom:100%;margin-bottom:2px;max-width:260px;padding:2px 6px;border-radius:5px;background:#111;color:#fff;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;z-index:6000}\n.lv-el:hover>.lv-tip{display:block}\n.lv-side{flex:1 1 320px;min-width:280px;max-height:78vh;overflow:auto}\n.lv-panel{display:none;background:#fff;border:1px solid rgba(0,0,0,.1);border-radius:10px;padding:12px}\n.lv-panel-none{display:block;color:#6e6e73}\n.lv-panel-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:8px}\n.lv-tag{font-weight:600;font-size:14px;word-break:break-all}\n.lv-rect{color:#6e6e73;font-size:12px;white-space:nowrap}\n.lv-sub{margin:12px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:#8a8a8e}\n.lv-locators{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}\n/* The whole card is the click target (children inherit the pointer and clicks\n bubble to it), so a click anywhere copies. Feedback is a card-wide overlay,\n not a small label, so it reads no matter where you clicked. */\n.lv-loc{position:relative;background:#f5f5f7;border-radius:7px;padding:6px 8px;cursor:pointer;outline:1px solid transparent;transition:background .15s,outline-color .15s}\n.lv-loc:hover{outline-color:rgba(59,130,246,.5)}\n.lv-loc:active{background:#e6e6ea}\n.lv-loc::after{content:\"Copied ✓\";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-weight:600;color:#fff;background:rgba(22,163,74,.94);border-radius:7px;opacity:0;pointer-events:none;transition:opacity .12s}\n.lv-loc.lv-copied::after{opacity:1}\n.lv-loc-head{display:flex;justify-content:space-between;align-items:center;gap:8px}\n.lv-loc-type{font-size:11px;color:#8a8a8e;text-transform:uppercase;letter-spacing:.03em}\n.lv-loc-hint{font-size:11px;color:#8a8a8e;opacity:0;transition:opacity .15s;white-space:nowrap}\n.lv-loc:hover .lv-loc-hint{opacity:.65}\n.lv-loc-value{display:block;margin-top:3px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;word-break:break-all}\n/* table-layout:fixed + a fixed first-column width keeps the attribute columns\n aligned identically across every element, so they do not jump when switching. */\n.lv-attrs{width:100%;border-collapse:collapse;table-layout:fixed}\n.lv-attrs th{width:42%;text-align:left;vertical-align:top;color:#6e6e73;font-weight:500;padding:2px 8px 2px 0;word-break:break-word}\n.lv-attrs td{vertical-align:top;padding:2px 0;word-break:break-all;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}\n\n@media (prefers-color-scheme:dark){\nbody{color:#e5e5e7;background:#1c1c1e}\n.lv-panel{background:#2c2c2e;border-color:rgba(255,255,255,.12)}\n.lv-loc{background:#3a3a3c}\n.lv-loc:active{background:#48484a}\n.lv-head input[type=text]{background:#3a3a3c;border-color:rgba(255,255,255,.2)}\n.lv-btn{background:#3a3a3c;border-color:rgba(255,255,255,.2)}\n.lv-btn:hover{background:#48484a}\n#lv-tree-toggle:checked~.lv-head .lv-btn-src{background:#54545a;border-color:rgba(255,255,255,.4)}\n.lv-tree{background:#2c2c2e;border-color:rgba(255,255,255,.12)}\n.lv-tree-head{background:#2c2c2e}\n}\n"
|
|
7
|
+
|
|
8
|
+
RUNTIME_JS = "\n/* Progressive enhancement — runs only when opened standalone (Allure's inline\n iframe strips this). Everything above already works without it. */\n(function(){\n var LV_ROOT=document.querySelector(\"[data-appium-live-view]\");\n var LV_B64=LV_ROOT?LV_ROOT.getAttribute(\"data-src\"):null;\n function lvDecodeB64(b){if(!b)return null;var s=atob(b),a=new Uint8Array(s.length);for(var i=0;i<s.length;i++)a[i]=s.charCodeAt(i);return new TextDecoder(\"utf-8\").decode(a);}\n var LV_XML=lvDecodeB64(LV_B64);\n function lvBlobFromDataUri(uri){var m=/^data:([^;,]*?)(;base64)?,([\\s\\S]*)$/.exec(uri||\"\");if(!m)return null;var mime=m[1]||\"application/octet-stream\",isB64=!!m[2],data=m[3],bytes;if(isB64){var bin=atob(data);bytes=new Uint8Array(bin.length);for(var i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i);}else{bytes=new TextEncoder().encode(decodeURIComponent(data));}return new Blob([bytes],{type:mime});}\n // XML/Image download controls. Click always downloads; it never navigates —\n // viewing a blob in a new tab is blocked inside Allure's sandboxed iframe\n // (blank page), and the Source tree already shows the XML in place. The href +\n // download attr keep right-click \"Save link as\" working too.\n function lvWireDownload(el,blob,name){\n if(!el||!blob)return;\n var url;try{url=URL.createObjectURL(blob);}catch(e){return;}\n el.href=url;el.setAttribute(\"download\",name);\n el.addEventListener(\"click\",function(e){\n e.preventDefault();\n var a=document.createElement(\"a\");a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();\n });\n }\n function lvSetLinks(){\n var ax=document.getElementById(\"lv-xml\");\n if(ax&&LV_XML){lvWireDownload(ax,new Blob([LV_XML],{type:\"application/xml\"}),\"page-source.xml\");}\n var ai=document.getElementById(\"lv-img\"),shot=document.querySelector(\".lv-shot\");\n if(ai&&shot){var blob=lvBlobFromDataUri(shot.getAttribute(\"src\"));if(blob){var ext=/svg/.test(blob.type)?\"svg\":(/(png|jpe?g|webp|gif)/.exec(blob.type)||[0,\"png\"])[1];lvWireDownload(ai,blob,\"screenshot.\"+ext);}}\n }\n lvSetLinks();\n function lvFallbackCopy(text){\n return new Promise(function(resolve,reject){\n try{\n var ta=document.createElement(\"textarea\");\n ta.value=text;ta.setAttribute(\"readonly\",\"\");\n ta.style.position=\"fixed\";ta.style.top=\"-1000px\";ta.style.opacity=\"0\";\n document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,text.length);\n var ok=document.execCommand(\"copy\");document.body.removeChild(ta);\n ok?resolve():reject(new Error(\"execCommand copy rejected\"));\n }catch(err){reject(err);}\n });\n }\n function lvCopy(text){\n if(navigator.clipboard&&navigator.clipboard.writeText){\n return navigator.clipboard.writeText(text).catch(function(){return lvFallbackCopy(text);});\n }\n return lvFallbackCopy(text);\n }\n function lvSelect(li){\n var val=li.querySelector(\".lv-loc-value\");\n if(!val)return;\n var r=document.createRange();r.selectNodeContents(val);\n var s=window.getSelection();s.removeAllRanges();s.addRange(r);\n }\n document.addEventListener(\"click\",function(e){\n var li=e.target.closest(\".lv-loc\");\n if(!li)return;\n var v=li.getAttribute(\"data-copy\")||\"\";\n var hint=li.querySelector(\".lv-loc-hint\");\n lvCopy(v).then(function(){\n li.classList.add(\"lv-copied\");\n clearTimeout(li.__lvT);\n li.__lvT=setTimeout(function(){li.classList.remove(\"lv-copied\");},1100);\n }).catch(function(){\n // Clipboard blocked — select the value so it can be copied manually.\n lvSelect(li);\n if(hint){hint.textContent=\"press ⌘/Ctrl+C\";clearTimeout(li.__lvT);li.__lvT=setTimeout(function(){hint.textContent=\"click to copy\";},1600);}\n });\n });\n\n // XPath tester — evaluate against the page source, highlight matches, and\n // report match count / no match / invalid XPath.\n var lvDoc=null,lvEls=null;\n if(LV_XML){\n try{\n lvDoc=new DOMParser().parseFromString(LV_XML,\"application/xml\");\n if(lvDoc.getElementsByTagName(\"parsererror\").length){lvDoc=null;}\n else{lvEls=lvDoc.getElementsByTagName(\"*\");}\n }catch(err){lvDoc=null;}\n }\n var xin=document.getElementById(\"lv-xpath\"),xstat=document.getElementById(\"lv-xstat\");\n function lvStat(t,cls){if(xstat){xstat.textContent=t;xstat.className=\"lv-xpath-status\"+(cls?\" \"+cls:\"\");}}\n function lvClearHits(){var h=document.querySelectorAll(\".lv-xhit,.lv-node-hit\");for(var i=0;i<h.length;i++){h[i].classList.remove(\"lv-xhit\");h[i].classList.remove(\"lv-node-hit\");}}\n function lvRunXpath(q){\n lvClearHits();\n if(!q){lvStat(\"\");return;}\n if(!lvDoc){lvStat(\"source unavailable\",\"err\");return;}\n var res;\n try{res=lvDoc.evaluate(q,lvDoc,null,7,null);}catch(err){lvStat(\"invalid XPath\",\"err\");return;}\n var n=res.snapshotLength;\n if(!n){lvStat(\"no match\",\"warn\");return;}\n var drawn=0,first=null;\n for(var i=0;i<n;i++){\n var idx=Array.prototype.indexOf.call(lvEls,res.snapshotItem(i));\n if(idx<0){continue;}\n var tn=document.querySelector(\".lv-node-\"+idx);if(tn){tn.classList.add(\"lv-node-hit\");}\n var ov=document.querySelector(\".lv-el-\"+idx);\n if(ov){ov.classList.add(\"lv-xhit\");drawn++;if(!first){first=ov;}}\n }\n if(first){first.scrollIntoView({block:\"nearest\",inline:\"nearest\"});}\n lvStat(n+\" match\"+(n===1?\"\":\"es\")+(drawn<n?\" · \"+drawn+\" on screen\":\"\"),\"ok\");\n }\n if(xin){\n var xt;\n xin.addEventListener(\"input\",function(){clearTimeout(xt);xt=setTimeout(function(){lvRunXpath(xin.value.trim());},200);});\n xin.addEventListener(\"keydown\",function(e){if(e.key===\"Enter\"){e.preventDefault();lvRunXpath(xin.value.trim());}});\n if(xin.value){lvRunXpath(xin.value.trim());}\n }\n document.addEventListener(\"keydown\",function(e){\n if(e.key===\"Escape\"&&document.activeElement!==xin){var n=document.getElementById(\"lv-none\");if(n){n.checked=true;}}\n });\n})();\n"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Suggested locators for a parsed node — a port of the JS ``lib/locators.js``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._parse import Node
|
|
6
|
+
|
|
7
|
+
_ANDROID_ID = "resource-id"
|
|
8
|
+
_ANDROID_DESC = "content-desc"
|
|
9
|
+
_ANDROID_TEXT = "text"
|
|
10
|
+
_IOS_NAME = "name"
|
|
11
|
+
_IOS_LABEL = "label"
|
|
12
|
+
_IOS_VALUE = "value"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def xpath_literal(value: str) -> str:
|
|
16
|
+
"""Quote a value for XPath, using ``concat()`` when it has both quote kinds."""
|
|
17
|
+
if "'" not in value:
|
|
18
|
+
return f"'{value}'"
|
|
19
|
+
if '"' not in value:
|
|
20
|
+
return f'"{value}"'
|
|
21
|
+
sep = ', ' + '"' + "'" + '"' + ', '
|
|
22
|
+
return "concat(" + sep.join(f"'{p}'" for p in value.split("'")) + ")"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def absolute_xpath(node: Node) -> str:
|
|
26
|
+
"""Absolute XPath using position predicates among same-tag siblings."""
|
|
27
|
+
segments: list[str] = []
|
|
28
|
+
current = node
|
|
29
|
+
while current and current.parent:
|
|
30
|
+
same = [c for c in current.parent.children if c.tag_name == current.tag_name]
|
|
31
|
+
seg = f"{current.tag_name}[{same.index(current) + 1}]" if len(same) > 1 else current.tag_name
|
|
32
|
+
segments.insert(0, seg)
|
|
33
|
+
current = current.parent
|
|
34
|
+
if current:
|
|
35
|
+
segments.insert(0, current.tag_name)
|
|
36
|
+
return "/" + "/".join(segments)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def suggest_locators(node: Node) -> list[dict]:
|
|
40
|
+
"""Ordered ``{type, using, value}`` suggestions, most specific first."""
|
|
41
|
+
a = node.attributes or {}
|
|
42
|
+
out: list[dict] = []
|
|
43
|
+
seen: set[str] = set()
|
|
44
|
+
|
|
45
|
+
def add(type_: str, using: str, value) -> None:
|
|
46
|
+
if not value:
|
|
47
|
+
return
|
|
48
|
+
key = f"{using}|{value}"
|
|
49
|
+
if key in seen:
|
|
50
|
+
return
|
|
51
|
+
seen.add(key)
|
|
52
|
+
out.append({"type": type_, "using": using, "value": value})
|
|
53
|
+
|
|
54
|
+
add("accessibility id", "accessibility id", a.get(_ANDROID_DESC) or a.get(_IOS_NAME))
|
|
55
|
+
add("id", "id", a.get(_ANDROID_ID))
|
|
56
|
+
if a.get(_ANDROID_ID):
|
|
57
|
+
add("xpath", "xpath", f"//*[@resource-id={xpath_literal(a[_ANDROID_ID])}]")
|
|
58
|
+
if a.get(_ANDROID_TEXT):
|
|
59
|
+
add("xpath", "xpath", f"//*[@text={xpath_literal(a[_ANDROID_TEXT])}]")
|
|
60
|
+
if a.get(_IOS_LABEL):
|
|
61
|
+
add("xpath", "xpath", f"//{node.tag_name}[@label={xpath_literal(a[_IOS_LABEL])}]")
|
|
62
|
+
if a.get(_IOS_VALUE):
|
|
63
|
+
add("xpath", "xpath", f"//{node.tag_name}[@value={xpath_literal(a[_IOS_VALUE])}]")
|
|
64
|
+
add("xpath (absolute)", "xpath", absolute_xpath(node))
|
|
65
|
+
return out
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Parse an Appium page source (XML) into a flat, render-ready node list.
|
|
2
|
+
|
|
3
|
+
A port of the JS ``lib/parse.js`` — same quote-aware scanner, entity decoding
|
|
4
|
+
and coordinate handling, so both renderers produce equivalent output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Iterator, Optional
|
|
11
|
+
|
|
12
|
+
_ENTITIES = {"<": "<", ">": ">", """: '"', "'": "'", "&": "&"}
|
|
13
|
+
_NAMED_RE = re.compile(r"&(?:lt|gt|quot|apos);")
|
|
14
|
+
_DEC_RE = re.compile(r"&#(\d+);")
|
|
15
|
+
_HEX_RE = re.compile(r"&#x([0-9a-fA-F]+);")
|
|
16
|
+
_ATTR_RE = re.compile(r"""([\w.\-:]+)\s*=\s*("([^"]*)"|'([^']*)')""")
|
|
17
|
+
_INT_RE = re.compile(r"\s*([+-]?\d+)")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def decode_entities(value: str) -> str:
|
|
21
|
+
"""Decode the XML entities Appium's serializers emit in attribute values."""
|
|
22
|
+
value = _NAMED_RE.sub(lambda m: _ENTITIES[m.group(0)], value)
|
|
23
|
+
value = _DEC_RE.sub(lambda m: chr(int(m.group(1))), value)
|
|
24
|
+
value = _HEX_RE.sub(lambda m: chr(int(m.group(1), 16)), value)
|
|
25
|
+
return value.replace("&", "&")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_int(value) -> Optional[int]:
|
|
29
|
+
"""Mirror JS ``parseInt``: leading optional sign + digits, else ``None``."""
|
|
30
|
+
if value is None:
|
|
31
|
+
return None
|
|
32
|
+
m = _INT_RE.match(str(value))
|
|
33
|
+
return int(m.group(1)) if m else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_coordinates(attributes: dict) -> Optional[dict]:
|
|
37
|
+
"""Extract ``{x1, y1, x2, y2}`` from an element's attributes.
|
|
38
|
+
|
|
39
|
+
Android uses ``bounds="[x1,y1][x2,y2]"``; iOS uses ``x``/``y``/``width``/
|
|
40
|
+
``height``. Returns ``None`` when there is no positional data.
|
|
41
|
+
"""
|
|
42
|
+
bounds = attributes.get("bounds")
|
|
43
|
+
if bounds:
|
|
44
|
+
parts = [p for p in re.split(r"[\[\],]", bounds) if p != ""]
|
|
45
|
+
nums = [_parse_int(p) for p in parts[:4]]
|
|
46
|
+
if len(nums) < 4 or any(n is None for n in nums):
|
|
47
|
+
return None
|
|
48
|
+
x1, y1, x2, y2 = nums
|
|
49
|
+
return {"x1": x1, "y1": y1, "x2": x2, "y2": y2}
|
|
50
|
+
|
|
51
|
+
x, y = attributes.get("x"), attributes.get("y")
|
|
52
|
+
if x is not None and y is not None:
|
|
53
|
+
xi, yi = _parse_int(x), _parse_int(y)
|
|
54
|
+
wi, hi = _parse_int(attributes.get("width")), _parse_int(attributes.get("height"))
|
|
55
|
+
if None in (xi, yi, wi, hi):
|
|
56
|
+
return None
|
|
57
|
+
return {"x1": xi, "y1": yi, "x2": xi + wi, "y2": yi + hi}
|
|
58
|
+
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_attributes(attr_text: str) -> dict:
|
|
63
|
+
attrs: dict = {}
|
|
64
|
+
for m in _ATTR_RE.finditer(attr_text):
|
|
65
|
+
raw = m.group(3) if m.group(3) is not None else m.group(4)
|
|
66
|
+
attrs[m.group(1)] = decode_entities(raw)
|
|
67
|
+
return attrs
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _tokenize(xml: str) -> Iterator[tuple]:
|
|
71
|
+
"""Yield ``(kind, name, attr_text)`` tokens; quote-aware so a ``>`` inside a
|
|
72
|
+
quoted attribute value does not end the tag. Skips prolog/comments/CDATA."""
|
|
73
|
+
i, n = 0, len(xml)
|
|
74
|
+
while i < n:
|
|
75
|
+
lt = xml.find("<", i)
|
|
76
|
+
if lt == -1:
|
|
77
|
+
break
|
|
78
|
+
if xml.startswith("<!--", lt):
|
|
79
|
+
end = xml.find("-->", lt + 4)
|
|
80
|
+
i = n if end == -1 else end + 3
|
|
81
|
+
continue
|
|
82
|
+
if xml[lt + 1 : lt + 2] in ("?", "!"):
|
|
83
|
+
end = xml.find(">", lt + 1)
|
|
84
|
+
i = n if end == -1 else end + 1
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
j, quote = lt + 1, None
|
|
88
|
+
while j < n:
|
|
89
|
+
ch = xml[j]
|
|
90
|
+
if quote:
|
|
91
|
+
if ch == quote:
|
|
92
|
+
quote = None
|
|
93
|
+
elif ch in ('"', "'"):
|
|
94
|
+
quote = ch
|
|
95
|
+
elif ch == ">":
|
|
96
|
+
break
|
|
97
|
+
j += 1
|
|
98
|
+
if j >= n:
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
inner = xml[lt + 1 : j].strip()
|
|
102
|
+
i = j + 1
|
|
103
|
+
if not inner:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
is_close = inner[0] == "/"
|
|
107
|
+
is_self = inner[-1] == "/"
|
|
108
|
+
if is_close:
|
|
109
|
+
inner = inner[1:].strip()
|
|
110
|
+
if is_self:
|
|
111
|
+
inner = inner[:-1].strip()
|
|
112
|
+
|
|
113
|
+
m = re.search(r"[\s/]", inner)
|
|
114
|
+
name = inner[: m.start()] if m else inner
|
|
115
|
+
attr_text = inner[m.start() :] if m else ""
|
|
116
|
+
|
|
117
|
+
if is_close:
|
|
118
|
+
yield ("close", name, "")
|
|
119
|
+
elif is_self:
|
|
120
|
+
yield ("self", name, attr_text)
|
|
121
|
+
else:
|
|
122
|
+
yield ("open", name, attr_text)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Node:
|
|
126
|
+
"""A parsed element: tag, attributes, tree position and (optional) rect."""
|
|
127
|
+
|
|
128
|
+
__slots__ = ("index", "path", "tag_name", "attributes", "depth", "parent", "children", "_child_count", "rect")
|
|
129
|
+
|
|
130
|
+
def __init__(self, index, path, tag_name, attributes, depth, parent, rect):
|
|
131
|
+
self.index = index
|
|
132
|
+
self.path = path
|
|
133
|
+
self.tag_name = tag_name
|
|
134
|
+
self.attributes = attributes
|
|
135
|
+
self.depth = depth
|
|
136
|
+
self.parent = parent
|
|
137
|
+
self.children: list = []
|
|
138
|
+
self._child_count = 0
|
|
139
|
+
self.rect = rect
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_source(xml: str) -> dict:
|
|
143
|
+
"""Parse page source into ``{"nodes", "root", "extents"}``.
|
|
144
|
+
|
|
145
|
+
``nodes`` is every element in document order; each has ``index``, ``path``,
|
|
146
|
+
``tag_name``, ``attributes``, ``depth``, ``parent``, ``children`` and ``rect``
|
|
147
|
+
(``{x1,y1,x2,y2,w,h}`` or ``None``). ``extents`` is the largest x2/y2 seen.
|
|
148
|
+
"""
|
|
149
|
+
nodes: list[Node] = []
|
|
150
|
+
stack: list[Node] = []
|
|
151
|
+
root: list = [None]
|
|
152
|
+
|
|
153
|
+
def push(name: str, attr_text: str, has_children: bool) -> None:
|
|
154
|
+
attributes = _parse_attributes(attr_text)
|
|
155
|
+
parent = stack[-1] if stack else None
|
|
156
|
+
sibling_index = parent._child_count if parent else 0
|
|
157
|
+
if parent:
|
|
158
|
+
parent._child_count += 1
|
|
159
|
+
if parent is None:
|
|
160
|
+
path = ""
|
|
161
|
+
else:
|
|
162
|
+
path = str(sibling_index) if parent.path == "" else f"{parent.path}.{sibling_index}"
|
|
163
|
+
|
|
164
|
+
coords = parse_coordinates(attributes)
|
|
165
|
+
rect = None
|
|
166
|
+
if coords:
|
|
167
|
+
rect = {**coords, "w": coords["x2"] - coords["x1"], "h": coords["y2"] - coords["y1"]}
|
|
168
|
+
|
|
169
|
+
node = Node(len(nodes), path, name, attributes, len(stack), parent, rect)
|
|
170
|
+
nodes.append(node)
|
|
171
|
+
if parent:
|
|
172
|
+
parent.children.append(node)
|
|
173
|
+
elif root[0] is None:
|
|
174
|
+
root[0] = node
|
|
175
|
+
if has_children:
|
|
176
|
+
stack.append(node)
|
|
177
|
+
|
|
178
|
+
for kind, name, attr_text in _tokenize(xml):
|
|
179
|
+
if kind == "open":
|
|
180
|
+
push(name, attr_text, True)
|
|
181
|
+
elif kind == "self":
|
|
182
|
+
push(name, attr_text, False)
|
|
183
|
+
elif kind == "close" and stack:
|
|
184
|
+
stack.pop()
|
|
185
|
+
|
|
186
|
+
width = height = 0
|
|
187
|
+
for node in nodes:
|
|
188
|
+
if node.rect:
|
|
189
|
+
width = max(width, node.rect["x2"])
|
|
190
|
+
height = max(height, node.rect["y2"])
|
|
191
|
+
|
|
192
|
+
return {"nodes": nodes, "root": root[0], "extents": {"width": width, "height": height}}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Build the interactive live-view HTML — a port of the JS ``lib/render.js``.
|
|
2
|
+
|
|
3
|
+
The browser CSS and runtime JS are shared verbatim with the JS renderer via
|
|
4
|
+
``_assets.py`` (regenerated by ``tools/extract-assets.mjs``); this module only
|
|
5
|
+
generates the per-node HTML (overlays, panels, source tree, selection rules).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
from typing import Optional, Union
|
|
12
|
+
|
|
13
|
+
from ._assets import BASE_CSS, RUNTIME_JS
|
|
14
|
+
from ._locators import suggest_locators
|
|
15
|
+
from ._parse import Node, parse_source
|
|
16
|
+
|
|
17
|
+
_TIP_ATTRS = ["resource-id", "content-desc", "text", "name", "label", "value", "type", "class"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _escape(value) -> str:
|
|
21
|
+
return (
|
|
22
|
+
str(value)
|
|
23
|
+
.replace("&", "&")
|
|
24
|
+
.replace("<", "<")
|
|
25
|
+
.replace(">", ">")
|
|
26
|
+
.replace('"', """)
|
|
27
|
+
.replace("'", "'")
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _xml_attr_escape(value) -> str:
|
|
32
|
+
return str(value).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _pct(part: float, whole: float) -> str:
|
|
36
|
+
return f"{(part / whole * 100):.4f}%" if whole > 0 else "0%"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_box(node: Node) -> bool:
|
|
40
|
+
return bool(node.rect and node.rect["w"] > 0 and node.rect["h"] > 0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _tip_label(node: Node) -> str:
|
|
44
|
+
a = node.attributes or {}
|
|
45
|
+
for key in _TIP_ATTRS:
|
|
46
|
+
if a.get(key):
|
|
47
|
+
return f"{node.tag_name} · {a[key]}"
|
|
48
|
+
return node.tag_name
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _serialize_element(node: Node) -> str:
|
|
52
|
+
attrs = "".join(f' {k}="{_xml_attr_escape(v)}"' for k, v in node.attributes.items())
|
|
53
|
+
kids = "".join(_serialize_element(c) for c in node.children)
|
|
54
|
+
return f"<{node.tag_name}{attrs}>{kids}</{node.tag_name}>"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _normalize_screenshot(screenshot: Union[bytes, bytearray, str, None]) -> str:
|
|
58
|
+
if not screenshot:
|
|
59
|
+
return ""
|
|
60
|
+
if isinstance(screenshot, (bytes, bytearray)):
|
|
61
|
+
return "data:image/png;base64," + base64.b64encode(bytes(screenshot)).decode("ascii")
|
|
62
|
+
if screenshot.startswith("data:"):
|
|
63
|
+
return screenshot
|
|
64
|
+
return f"data:image/png;base64,{screenshot}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _render_panel(node: Node) -> str:
|
|
68
|
+
attrs = node.attributes or {}
|
|
69
|
+
rows = "".join(f"<tr><th>{_escape(k)}</th><td>{_escape(v)}</td></tr>" for k, v in attrs.items())
|
|
70
|
+
locators = "".join(
|
|
71
|
+
f'''
|
|
72
|
+
<li class="lv-loc" data-copy="{_escape(loc["value"])}" title="Click to copy">
|
|
73
|
+
<div class="lv-loc-head"><span class="lv-loc-type">{_escape(loc["type"])}</span>
|
|
74
|
+
<span class="lv-loc-hint">click to copy</span></div>
|
|
75
|
+
<code class="lv-loc-value">{_escape(loc["value"])}</code>
|
|
76
|
+
</li>'''
|
|
77
|
+
for loc in suggest_locators(node)
|
|
78
|
+
)
|
|
79
|
+
rect = (
|
|
80
|
+
f'<div class="lv-rect">x {node.rect["x1"]}, y {node.rect["y1"]} · {node.rect["w"]}×{node.rect["h"]}</div>'
|
|
81
|
+
if node.rect
|
|
82
|
+
else ""
|
|
83
|
+
)
|
|
84
|
+
return f'''
|
|
85
|
+
<div class="lv-panel lv-panel-{node.index}">
|
|
86
|
+
<div class="lv-panel-head"><span class="lv-tag">{_escape(node.tag_name)}</span>{rect}</div>
|
|
87
|
+
<div class="lv-sub">Suggested locators</div>
|
|
88
|
+
<ul class="lv-locators">{locators}</ul>
|
|
89
|
+
<div class="lv-sub">Attributes</div>
|
|
90
|
+
<table class="lv-attrs">{rows}</table>
|
|
91
|
+
</div>'''
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_live_view_html(
|
|
95
|
+
xml: Optional[str] = None,
|
|
96
|
+
screenshot: Union[bytes, bytearray, str, None] = None,
|
|
97
|
+
*,
|
|
98
|
+
title: Optional[str] = None,
|
|
99
|
+
platform_name: Optional[str] = None,
|
|
100
|
+
selected_path: Optional[str] = None,
|
|
101
|
+
parsed: Optional[dict] = None,
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Render page source + screenshot into a standalone, interactive HTML page.
|
|
104
|
+
|
|
105
|
+
``screenshot`` may be raw PNG bytes (e.g. ``driver.get_screenshot_as_png()``),
|
|
106
|
+
a base64 string (``get_screenshot_as_base64()``), or a full ``data:`` URI.
|
|
107
|
+
Returns a complete HTML document ready to attach, e.g.::
|
|
108
|
+
|
|
109
|
+
allure.attach(html, "Live view", allure.attachment_type.HTML)
|
|
110
|
+
"""
|
|
111
|
+
parsed = parsed or parse_source(xml or "")
|
|
112
|
+
nodes: list[Node] = parsed["nodes"]
|
|
113
|
+
extents = parsed["extents"]
|
|
114
|
+
root = parsed["root"]
|
|
115
|
+
drawable = [n for n in nodes if _is_box(n)]
|
|
116
|
+
shot = _normalize_screenshot(screenshot)
|
|
117
|
+
title = title or "Appium live view"
|
|
118
|
+
|
|
119
|
+
xml_b64 = ""
|
|
120
|
+
if root is not None:
|
|
121
|
+
source = '<?xml version="1.0" encoding="UTF-8"?>' + _serialize_element(root)
|
|
122
|
+
xml_b64 = base64.b64encode(source.encode("utf-8")).decode("ascii")
|
|
123
|
+
|
|
124
|
+
selected = None
|
|
125
|
+
if selected_path is not None:
|
|
126
|
+
selected = next((n for n in nodes if n.path == selected_path), None)
|
|
127
|
+
|
|
128
|
+
radios = "\n".join(
|
|
129
|
+
[
|
|
130
|
+
'<input type="checkbox" id="lv-tree-toggle" class="lv-r">',
|
|
131
|
+
f'<input type="radio" name="lv-sel" id="lv-none" class="lv-r"{"" if selected else " checked"}>',
|
|
132
|
+
*[
|
|
133
|
+
f'<input type="radio" name="lv-sel" id="lv-r-{n.index}" class="lv-r"'
|
|
134
|
+
f'{" checked" if (selected and selected.index == n.index) else ""}>'
|
|
135
|
+
for n in nodes
|
|
136
|
+
],
|
|
137
|
+
]
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
overlays = "\n".join(
|
|
141
|
+
(
|
|
142
|
+
lambda r, style: (
|
|
143
|
+
f'<label class="lv-el lv-el-{n.index}" for="lv-r-{n.index}" style="{style}">'
|
|
144
|
+
f'<span class="lv-tip">{_escape(_tip_label(n))}</span></label>'
|
|
145
|
+
)
|
|
146
|
+
)(
|
|
147
|
+
n.rect,
|
|
148
|
+
f'left:{_pct(n.rect["x1"], extents["width"])};top:{_pct(n.rect["y1"], extents["height"])};'
|
|
149
|
+
f'width:{_pct(n.rect["w"], extents["width"])};height:{_pct(n.rect["h"], extents["height"])};z-index:{n.depth}',
|
|
150
|
+
)
|
|
151
|
+
for n in drawable
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _tree_row(n: Node) -> str:
|
|
155
|
+
a = n.attributes or {}
|
|
156
|
+
key = a.get("resource-id") or a.get("content-desc") or a.get("text") or a.get("name") or a.get("label") or a.get("value") or ""
|
|
157
|
+
attr = f' <span class="lv-node-attr">{_escape(str(key)[:40])}</span>' if key else ""
|
|
158
|
+
nobox = "" if _is_box(n) else ' <span class="lv-node-nobox" title="no bounds — not clickable on the screenshot">◌</span>'
|
|
159
|
+
return (
|
|
160
|
+
f'<label class="lv-node lv-node-{n.index}" for="lv-r-{n.index}" style="padding-left:{6 + n.depth * 13}px" title="{_escape(n.tag_name)}">'
|
|
161
|
+
f'<span class="lv-node-tag">{_escape(n.tag_name)}</span>{attr}{nobox}</label>'
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
tree_rows = "\n".join(_tree_row(n) for n in nodes)
|
|
165
|
+
panels = "\n".join(_render_panel(n) for n in nodes)
|
|
166
|
+
|
|
167
|
+
selection_css = "".join(
|
|
168
|
+
f"#lv-r-{n.index}:checked~.lv-main .lv-panel-{n.index}{{display:block}}"
|
|
169
|
+
f"#lv-r-{n.index}:checked~.lv-main .lv-node-{n.index}{{background:rgba(229,72,77,.18);outline:1px solid rgba(229,72,77,.5)}}"
|
|
170
|
+
+ (
|
|
171
|
+
f"#lv-r-{n.index}:checked~.lv-main .lv-el-{n.index}{{outline:2px solid #e5484d;background:rgba(229,72,77,.16)}}"
|
|
172
|
+
if _is_box(n)
|
|
173
|
+
else ""
|
|
174
|
+
)
|
|
175
|
+
for n in nodes
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
meta = " · ".join(
|
|
179
|
+
p
|
|
180
|
+
for p in [
|
|
181
|
+
_escape(platform_name) if platform_name else None,
|
|
182
|
+
f"{len(drawable)} elements",
|
|
183
|
+
f'{extents["width"]}×{extents["height"]}' if extents["width"] and extents["height"] else None,
|
|
184
|
+
]
|
|
185
|
+
if p
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
tools = ""
|
|
189
|
+
if nodes or xml_b64 or shot:
|
|
190
|
+
source_btn = (
|
|
191
|
+
'<label for="lv-tree-toggle" class="lv-btn lv-btn-src" title="Show the source tree — every node is selectable, including ones without bounds">Source</label>'
|
|
192
|
+
if nodes
|
|
193
|
+
else ""
|
|
194
|
+
)
|
|
195
|
+
xml_btn = (
|
|
196
|
+
'<a id="lv-xml" class="lv-btn" download="page-source.xml" title="Download the page source (XML). The Source tree shows it in place.">XML</a>'
|
|
197
|
+
if xml_b64
|
|
198
|
+
else ""
|
|
199
|
+
)
|
|
200
|
+
img_btn = (
|
|
201
|
+
'<a id="lv-img" class="lv-btn" download="screenshot" title="Download the screenshot">Image</a>' if shot else ""
|
|
202
|
+
)
|
|
203
|
+
tools = f'<span class="lv-tools">{source_btn}{xml_btn}{img_btn}</span>'
|
|
204
|
+
|
|
205
|
+
aspect = f'{extents["width"] or 9} / {extents["height"] or 16}'
|
|
206
|
+
stage_inner = (f'<img class="lv-shot" src="{shot}" alt="screenshot">' if shot else "") + overlays
|
|
207
|
+
|
|
208
|
+
return f"""<!doctype html>
|
|
209
|
+
<html lang="en">
|
|
210
|
+
<head>
|
|
211
|
+
<meta charset="utf-8">
|
|
212
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
213
|
+
<title>{_escape(title)}</title>
|
|
214
|
+
</head>
|
|
215
|
+
<body>
|
|
216
|
+
<style>{BASE_CSS}{selection_css}</style>
|
|
217
|
+
<div class="lv-root" data-appium-live-view="1" data-src="{xml_b64}">
|
|
218
|
+
{radios}
|
|
219
|
+
<div class="lv-head">
|
|
220
|
+
<h1>{_escape(title)}</h1>
|
|
221
|
+
<span class="lv-meta">{meta}</span>
|
|
222
|
+
{tools}
|
|
223
|
+
<span class="lv-xpath">
|
|
224
|
+
<input type="text" id="lv-xpath" placeholder="Test XPath, e.g. //*[@text='OK']" autocomplete="off" spellcheck="false">
|
|
225
|
+
<span class="lv-xpath-status" id="lv-xstat"></span>
|
|
226
|
+
</span>
|
|
227
|
+
</div>
|
|
228
|
+
<div class="lv-main">
|
|
229
|
+
<div class="lv-stage" style="aspect-ratio:{aspect}">
|
|
230
|
+
{stage_inner}
|
|
231
|
+
</div>
|
|
232
|
+
<nav class="lv-tree" aria-label="Source tree">
|
|
233
|
+
<div class="lv-tree-head">Source tree · {len(nodes)} nodes</div>
|
|
234
|
+
<div class="lv-tree-body">{tree_rows}</div>
|
|
235
|
+
</nav>
|
|
236
|
+
<div class="lv-side">
|
|
237
|
+
<div class="lv-panel lv-panel-none">Hover an element to preview it; click to pin its attributes and locators here.</div>
|
|
238
|
+
{panels}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
<script>{RUNTIME_JS}</script>
|
|
243
|
+
</body>
|
|
244
|
+
</html>"""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: appium-live-view
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build a standalone, interactive HTML live view from an Appium page source (XML) + screenshot
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/v-dermichev/appium-live-view-plugin
|
|
7
|
+
Keywords: appium,allure,inspector,screenshot,xml,testing
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# appium-live-view (Python)
|
|
14
|
+
|
|
15
|
+
Build a standalone, interactive HTML **live view** — the Appium Inspector
|
|
16
|
+
experience (hover to highlight, click to inspect attributes + locators, a
|
|
17
|
+
selectable source tree, an XPath tester) — from a page source (XML) + screenshot.
|
|
18
|
+
No server, no browser, no Node: one function returns a self-contained HTML string
|
|
19
|
+
you can attach to Allure or save anywhere.
|
|
20
|
+
|
|
21
|
+
It's the Python counterpart of the [Appium plugin / JS renderer](../README.md).
|
|
22
|
+
The interactive CSS + JS are shared verbatim with the JS renderer (generated by
|
|
23
|
+
`tools/extract-assets.mjs`), so both produce the same live view.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install ./python # from this repo
|
|
29
|
+
# or, once published:
|
|
30
|
+
pip install appium-live-view
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from appium_live_view import build_live_view_html
|
|
37
|
+
import allure
|
|
38
|
+
|
|
39
|
+
html = build_live_view_html(
|
|
40
|
+
driver.page_source, # XML string
|
|
41
|
+
driver.get_screenshot_as_png(), # raw PNG bytes (also accepts base64 or a data: URI)
|
|
42
|
+
title="Login screen",
|
|
43
|
+
platform_name=driver.capabilities.get("platformName"),
|
|
44
|
+
)
|
|
45
|
+
allure.attach(html, "Live view", allure.attachment_type.HTML)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
A pytest failure hook is a natural home:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import pytest, allure
|
|
52
|
+
from appium_live_view import build_live_view_html
|
|
53
|
+
|
|
54
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
55
|
+
def pytest_runtest_makereport(item, call):
|
|
56
|
+
outcome = yield
|
|
57
|
+
report = outcome.get_result()
|
|
58
|
+
driver = getattr(item.instance, "driver", None)
|
|
59
|
+
if report.when == "call" and report.failed and driver is not None:
|
|
60
|
+
html = build_live_view_html(driver.page_source, driver.get_screenshot_as_png())
|
|
61
|
+
allure.attach(html, "Live view", allure.attachment_type.HTML)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API
|
|
65
|
+
|
|
66
|
+
`build_live_view_html(xml=None, screenshot=None, *, title=None, platform_name=None, selected_path=None, parsed=None) -> str`
|
|
67
|
+
|
|
68
|
+
- `xml` — Appium page source (`driver.page_source`).
|
|
69
|
+
- `screenshot` — raw PNG `bytes`, a base64 `str`, or a full `data:` URI. Optional
|
|
70
|
+
(overlays still work without it).
|
|
71
|
+
- `selected_path` — dot-separated node path to pre-select (e.g. the element a step
|
|
72
|
+
acted on).
|
|
73
|
+
|
|
74
|
+
Also exported: `parse_source`, `parse_coordinates`, `suggest_locators`,
|
|
75
|
+
`absolute_xpath`.
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- **Interactivity inline in Allure 3:** hover and click-to-pin and the source tree
|
|
80
|
+
are pure CSS and work inline; copy, the XPath tester and the download buttons
|
|
81
|
+
need JavaScript, which Allure strips from inline attachments. Open the attachment
|
|
82
|
+
standalone, or apply the report patch in
|
|
83
|
+
[`../examples/allure-inline-interactive/`](../examples/allure-inline-interactive/).
|
|
84
|
+
- **Requires Python 3.8+**, no third-party dependencies.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/appium_live_view/__init__.py
|
|
5
|
+
src/appium_live_view/_assets.py
|
|
6
|
+
src/appium_live_view/_locators.py
|
|
7
|
+
src/appium_live_view/_parse.py
|
|
8
|
+
src/appium_live_view/_render.py
|
|
9
|
+
src/appium_live_view.egg-info/PKG-INFO
|
|
10
|
+
src/appium_live_view.egg-info/SOURCES.txt
|
|
11
|
+
src/appium_live_view.egg-info/dependency_links.txt
|
|
12
|
+
src/appium_live_view.egg-info/top_level.txt
|
|
13
|
+
tests/test_render.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
appium_live_view
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
6
|
+
|
|
7
|
+
from appium_live_view import ( # noqa: E402
|
|
8
|
+
absolute_xpath,
|
|
9
|
+
build_live_view_html,
|
|
10
|
+
parse_coordinates,
|
|
11
|
+
parse_source,
|
|
12
|
+
suggest_locators,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Mirrors of the JS test fixtures (test/fixtures.js).
|
|
16
|
+
ANDROID_XML = """<?xml version='1.0' encoding='UTF-8'?>
|
|
17
|
+
<hierarchy rotation="0">
|
|
18
|
+
<android.widget.FrameLayout package="com.example.app" bounds="[0,0][1080,2400]">
|
|
19
|
+
<android.widget.TextView resource-id="com.example.app:id/title" text="A > B & C" bounds="[40,120][1040,220]"/>
|
|
20
|
+
<android.widget.LinearLayout bounds="[0,300][1080,900]">
|
|
21
|
+
<android.widget.EditText resource-id="com.example.app:id/username" text="" content-desc="Username field" bounds="[60,360][1020,470]"/>
|
|
22
|
+
<android.widget.EditText resource-id="com.example.app:id/password" password="true" bounds="[60,520][1020,630]"/>
|
|
23
|
+
</android.widget.LinearLayout>
|
|
24
|
+
<android.widget.Button resource-id="com.example.app:id/login" content-desc="Log in" text="LOG IN" bounds="[60,1000][1020,1140]"/>
|
|
25
|
+
</android.widget.FrameLayout>
|
|
26
|
+
</hierarchy>"""
|
|
27
|
+
|
|
28
|
+
IOS_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
|
29
|
+
<AppiumAUT>
|
|
30
|
+
<XCUIElementTypeApplication type="XCUIElementTypeApplication" name="MyApp" x="0" y="0" width="390" height="844">
|
|
31
|
+
<XCUIElementTypeButton type="XCUIElementTypeButton" name="login" label="Log in" x="24" y="740" width="342" height="48"/>
|
|
32
|
+
</XCUIElementTypeApplication>
|
|
33
|
+
</AppiumAUT>"""
|
|
34
|
+
|
|
35
|
+
PNG_1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_parse_coordinates_android_and_ios():
|
|
39
|
+
assert parse_coordinates({"bounds": "[10,20][110,220]"}) == {"x1": 10, "y1": 20, "x2": 110, "y2": 220}
|
|
40
|
+
assert parse_coordinates({"x": "24", "y": "80", "width": "342", "height": "34"}) == {
|
|
41
|
+
"x1": 24,
|
|
42
|
+
"y1": 80,
|
|
43
|
+
"x2": 366,
|
|
44
|
+
"y2": 114,
|
|
45
|
+
}
|
|
46
|
+
assert parse_coordinates({"name": "foo"}) is None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_quote_aware_scanner_and_entities():
|
|
50
|
+
nodes = parse_source(ANDROID_XML)["nodes"]
|
|
51
|
+
title = next(n for n in nodes if n.attributes.get("resource-id", "").endswith("/title"))
|
|
52
|
+
# literal ">" inside text="A > B & C" must not end the tag; entity decoded
|
|
53
|
+
assert title.attributes["text"] == "A > B & C"
|
|
54
|
+
assert title.rect == {"x1": 40, "y1": 120, "x2": 1040, "y2": 220, "w": 1000, "h": 100}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_extents_and_absolute_xpath():
|
|
58
|
+
parsed = parse_source(ANDROID_XML)
|
|
59
|
+
assert parsed["extents"] == {"width": 1080, "height": 2400}
|
|
60
|
+
assert parsed["root"].tag_name == "hierarchy"
|
|
61
|
+
edits = [n for n in parsed["nodes"] if n.tag_name == "android.widget.EditText"]
|
|
62
|
+
assert len(edits) == 2
|
|
63
|
+
xp = absolute_xpath(edits[1])
|
|
64
|
+
assert xp.startswith("/hierarchy/") and xp.endswith("android.widget.EditText[2]")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_suggest_locators_order():
|
|
68
|
+
login = next(n for n in parse_source(ANDROID_XML)["nodes"] if n.attributes.get("resource-id", "").endswith("/login"))
|
|
69
|
+
locs = suggest_locators(login)
|
|
70
|
+
assert locs[0]["using"] == "accessibility id" and locs[0]["value"] == "Log in"
|
|
71
|
+
assert any(l["using"] == "id" and l["value"] == "com.example.app:id/login" for l in locs)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_build_html_structure():
|
|
75
|
+
parsed = parse_source(ANDROID_XML)
|
|
76
|
+
nodes = parsed["nodes"]
|
|
77
|
+
drawable = [n for n in nodes if n.rect and n.rect["w"] > 0 and n.rect["h"] > 0]
|
|
78
|
+
html = build_live_view_html(ANDROID_XML, PNG_1x1, platform_name="Android")
|
|
79
|
+
|
|
80
|
+
import re
|
|
81
|
+
|
|
82
|
+
assert html.startswith("<!doctype html>")
|
|
83
|
+
assert html.count('class="lv-el ') == len(drawable) # overlays: drawable only
|
|
84
|
+
assert len(re.findall(r'class="lv-panel lv-panel-\d', html)) == len(nodes) # panels: every node (excl. -none)
|
|
85
|
+
assert html.count('class="lv-node lv-node-') == len(nodes) # tree rows: every node
|
|
86
|
+
assert html.count('name="lv-sel"') == len(nodes) + 1 # radios + "none"
|
|
87
|
+
assert f"data:image/png;base64,{PNG_1x1}" in html
|
|
88
|
+
# full tag name in the tree (not truncated)
|
|
89
|
+
assert ">android.widget.EditText<" in html
|
|
90
|
+
# marker + embedded source for the runtime / XPath tester
|
|
91
|
+
assert 'data-appium-live-view="1"' in html
|
|
92
|
+
assert 'data-src="' in html
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_screenshot_accepts_bytes():
|
|
96
|
+
html = build_live_view_html(IOS_XML, base64.b64decode(PNG_1x1))
|
|
97
|
+
assert f"data:image/png;base64,{PNG_1x1}" in html
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_escapes_attribute_values():
|
|
101
|
+
html = build_live_view_html(ANDROID_XML, PNG_1x1)
|
|
102
|
+
assert "A > B & C" in html
|
|
103
|
+
assert "A > B & C" not in html
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_selected_path_prechecks_radio():
|
|
107
|
+
parsed = parse_source(IOS_XML)
|
|
108
|
+
login = next(n for n in parsed["nodes"] if n.attributes.get("name") == "login")
|
|
109
|
+
html = build_live_view_html(parsed=parsed, screenshot=PNG_1x1, selected_path=login.path)
|
|
110
|
+
assert f'id="lv-r-{login.index}" class="lv-r" checked' in html
|
|
111
|
+
assert 'id="lv-none" class="lv-r" checked' not in html
|