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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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 = {"&lt;": "<", "&gt;": ">", "&quot;": '"', "&apos;": "'", "&amp;": "&"}
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("&amp;", "&")
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("&", "&amp;")
24
+ .replace("<", "&lt;")
25
+ .replace(">", "&gt;")
26
+ .replace('"', "&quot;")
27
+ .replace("'", "&#39;")
28
+ )
29
+
30
+
31
+ def _xml_attr_escape(value) -> str:
32
+ return str(value).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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
+ 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 &amp; 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 &gt; B &amp; 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