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:
Kenanandnesquena-hermes authored and GitHub committed 2026-08-21 06:32:50 +00:00
1 parent 8066462fcc
commit 6c0f2c765c
2 files changed
+175 -2

No files matched your search

+10 -2
View File
@@ -2104,9 +2104,17 @@ $('btnDownload').onclick=()=>{
const a=document.createElement('a');a.href=URL.createObjectURL(blob); const a=document.createElement('a');a.href=URL.createObjectURL(blob);
a.download=`hermes-${S.session.session_id}.md`;a.click();URL.revokeObjectURL(a.href); 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=()=>{ $('btnExportJSON').onclick=()=>{
if(!S.session)return; 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; const a=document.createElement('a');a.href=url;
a.download=`hermes-${S.session.session_id}.json`;a.click(); 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. // 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 clean={};for(const k in palette){if(palette[k])clean[k]=palette[k];}
const paletteB64=btoa(unescape(encodeURIComponent(JSON.stringify(clean)))); 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; const a=document.createElement('a');a.href=url;
a.download=`hermes-${sid}.html`;a.click(); a.download=`hermes-${sid}.html`;a.click();
} }
+165
View File
@@ -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"