fix(export): resolve session exports against document base URI (#7189)
* fix(export): resolve session exports against document base URI * test(export): cover subpath deep links --------- Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
This commit is contained in:
1 parent
8066462fcc
commit
6c0f2c765c
2 files changed
+175
-2
No files matched your search
+10
-2
@@ -2104,9 +2104,17 @@ $('btnDownload').onclick=()=>{
|
||||
const a=document.createElement('a');a.href=URL.createObjectURL(blob);
|
||||
a.download=`hermes-${S.session.session_id}.md`;a.click();URL.revokeObjectURL(a.href);
|
||||
};
|
||||
function _buildSessionExportUrl(sessionId,params){
|
||||
const url=new URL('api/session/export',document.baseURI||location.href);
|
||||
url.searchParams.set('session_id',String(sessionId||''));
|
||||
Object.entries(params||{}).forEach(([key,value])=>{
|
||||
if(value!==undefined&&value!==null)url.searchParams.set(key,String(value));
|
||||
});
|
||||
return url.href;
|
||||
}
|
||||
$('btnExportJSON').onclick=()=>{
|
||||
if(!S.session)return;
|
||||
const url=`/api/session/export?session_id=${encodeURIComponent(S.session.session_id)}`;
|
||||
const url=_buildSessionExportUrl(S.session.session_id);
|
||||
const a=document.createElement('a');a.href=url;
|
||||
a.download=`hermes-${S.session.session_id}.json`;a.click();
|
||||
};
|
||||
@@ -2183,7 +2191,7 @@ function exportSessionHTML(session){
|
||||
// Drop empties so the inlined fallback keeps working for anything we couldn't read.
|
||||
const clean={};for(const k in palette){if(palette[k])clean[k]=palette[k];}
|
||||
const paletteB64=btoa(unescape(encodeURIComponent(JSON.stringify(clean))));
|
||||
const url=`/api/session/export?session_id=${encodeURIComponent(sid)}&format=html&theme=${theme}&palette=${encodeURIComponent(paletteB64)}`;
|
||||
const url=_buildSessionExportUrl(sid,{format:'html',theme,palette:paletteB64});
|
||||
const a=document.createElement('a');a.href=url;
|
||||
a.download=`hermes-${sid}.html`;a.click();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Regression coverage for browser session-export URLs under subpath mounts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
BOOT_JS = ROOT / "static" / "boot.js"
|
||||
INDEX_HTML = ROOT / "static" / "index.html"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _boot_source() -> str:
|
||||
return BOOT_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_source(source: str, name: str) -> str:
|
||||
"""Extract one product function for a small, browser-free execution harness."""
|
||||
marker = f"function {name}("
|
||||
start = source.find(marker)
|
||||
assert start >= 0, f"{name} must be defined in product source"
|
||||
brace = source.find("{", start)
|
||||
assert brace >= 0
|
||||
depth = 0
|
||||
for index in range(brace, len(source)):
|
||||
if source[index] == "{":
|
||||
depth += 1
|
||||
elif source[index] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start : index + 1]
|
||||
raise AssertionError(f"unterminated {name}")
|
||||
|
||||
|
||||
def _onclick_source(source: str, element_id: str) -> str:
|
||||
marker = f"$('{element_id}').onclick="
|
||||
start = source.find(marker)
|
||||
assert start >= 0, f"{element_id} must have an export click handler"
|
||||
arrow = source.find("()=>", start)
|
||||
assert arrow >= 0
|
||||
expression_start = arrow + len("()=>")
|
||||
if source[expression_start] != "{":
|
||||
end = source.find(";", expression_start)
|
||||
assert end >= 0
|
||||
return source[arrow:end]
|
||||
brace = expression_start
|
||||
assert brace >= 0
|
||||
depth = 0
|
||||
for index in range(brace, len(source)):
|
||||
if source[index] == "{":
|
||||
depth += 1
|
||||
elif source[index] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[arrow : index + 1]
|
||||
raise AssertionError(f"unterminated {element_id} click handler")
|
||||
|
||||
|
||||
def _run_export_actions(base_uri: str) -> list[dict[str, str]]:
|
||||
source = _boot_source()
|
||||
helper = _function_source(source, "_buildSessionExportUrl")
|
||||
html_export = _function_source(source, "exportSessionHTML")
|
||||
json_handler = _onclick_source(source, "btnExportJSON")
|
||||
html_handler = _onclick_source(source, "btnExportHTML")
|
||||
program = json.dumps(
|
||||
f"{helper}\n{html_export}\nreturn [{json_handler}, {html_handler}];"
|
||||
)
|
||||
script = f"""
|
||||
const document = {{
|
||||
baseURI: {json.dumps(base_uri)},
|
||||
documentElement: {{classList: {{contains: () => true}}}},
|
||||
createElement: () => ({{click() {{ clicked.push(this); }}}}),
|
||||
}};
|
||||
const location = {{href: {json.dumps(base_uri)}}};
|
||||
const clicked = [];
|
||||
const elements = {{btnExportJSON: {{}}, btnExportHTML: {{}}}};
|
||||
const $ = (id) => elements[id];
|
||||
const S = {{session: {{session_id: 'session /?&=✓'}}}};
|
||||
const getComputedStyle = () => ({{getPropertyValue: () => ''}});
|
||||
const handlers = new Function(
|
||||
'document', 'location', 'URL', '$', 'S', 'getComputedStyle', 'btoa', 'unescape',
|
||||
{program},
|
||||
)(document, location, URL, $, S, getComputedStyle, btoa, unescape);
|
||||
handlers[0]();
|
||||
handlers[1]();
|
||||
const urls = clicked.map((anchor) => {{
|
||||
const url = new URL(anchor.href);
|
||||
return {{
|
||||
pathname: url.pathname,
|
||||
session_id: url.searchParams.get('session_id'),
|
||||
format: url.searchParams.get('format') || '',
|
||||
theme: url.searchParams.get('theme') || '',
|
||||
palette: Boolean(url.searchParams.get('palette')),
|
||||
}};
|
||||
}});
|
||||
console.log(JSON.stringify(urls));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for browser URL checks")
|
||||
def test_export_builder_keeps_root_and_subpath_mounts() -> None:
|
||||
for base_uri, expected_path in (
|
||||
("https://example.test/", "/api/session/export"),
|
||||
("https://example.test/hermes-webui/", "/hermes-webui/api/session/export"),
|
||||
):
|
||||
json_export, html_export = _run_export_actions(base_uri)
|
||||
assert json_export == {
|
||||
"pathname": expected_path,
|
||||
"session_id": "session /?&=✓",
|
||||
"format": "",
|
||||
"theme": "",
|
||||
"palette": False,
|
||||
}
|
||||
assert html_export == {
|
||||
"pathname": expected_path,
|
||||
"session_id": "session /?&=✓",
|
||||
"format": "html",
|
||||
"theme": "dark",
|
||||
"palette": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for browser URL checks")
|
||||
def test_export_actions_keep_the_app_base_from_a_session_deep_link() -> None:
|
||||
source = INDEX_HTML.read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
r"<script>(\(function\(\)\{var path=location\.pathname.*?\}\)\(\))</script>",
|
||||
source,
|
||||
)
|
||||
assert match, "index.html must establish its document base before app scripts load"
|
||||
script = f"""
|
||||
let emitted = '';
|
||||
const location = {{origin: 'https://example.test', pathname: '/hermes-webui/session/deep-link'}};
|
||||
const document = {{write(value) {{ emitted += value; }}}};
|
||||
{match.group(1)};
|
||||
console.log(emitted);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
base_match = re.search(r'href="([^"]+)"', result.stdout)
|
||||
assert base_match
|
||||
|
||||
json_export, html_export = _run_export_actions(base_match.group(1))
|
||||
assert json_export["pathname"] == "/hermes-webui/api/session/export"
|
||||
assert html_export["pathname"] == "/hermes-webui/api/session/export"
|
||||
Reference in new issue
Block a user