fix: retain child provenance in htmx page swaps
This commit is contained in:
parent
e9f235505f
commit
c8e50cb629
3 changed files with 116 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
node_modules
|
node_modules
|
||||||
|
.venv
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dbs
|
dbs
|
||||||
.env
|
.env
|
||||||
|
|
|
||||||
29
index.js
29
index.js
|
|
@ -441,6 +441,28 @@ function decorateFragment(html, { headInjection, source } = {}) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `res.render()` commonly returns a complete document even when it is serving
|
||||||
|
// an HTMX request. HTMX swaps that document's body into the current page, so
|
||||||
|
// preserve the template provenance from its body on every swapped root before
|
||||||
|
// reducing it to a fragment. Without this, templates from an hx-get child
|
||||||
|
// appear to belong to the outer page; saving `message-raw` in /sheepgpt then
|
||||||
|
// incorrectly refreshes /sheepgpt instead of the embedded /chat instance.
|
||||||
|
function fragmentFromPage(html) {
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const body = $("body");
|
||||||
|
const templateAttrs = Object.fromEntries(
|
||||||
|
Object.entries(body[0]?.attribs || {}).filter(([name]) =>
|
||||||
|
name.startsWith("data-bliss-template"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (Object.keys(templateAttrs).length) {
|
||||||
|
for (const child of body.children().toArray()) {
|
||||||
|
Object.assign(child.attribs, templateAttrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return body.html();
|
||||||
|
}
|
||||||
|
|
||||||
// Single entry point for turning rendered HTML into a response body. A full
|
// Single entry point for turning rendered HTML into a response body. A full
|
||||||
// document (or any non-htmx request) becomes a decorated page; an htmx partial
|
// document (or any non-htmx request) becomes a decorated page; an htmx partial
|
||||||
// carrying provenance becomes a decorated fragment; anything else passes through.
|
// carrying provenance becomes a decorated fragment; anything else passes through.
|
||||||
|
|
@ -448,11 +470,12 @@ function decorate(html, { headInjection, source, fragment } = {}) {
|
||||||
const lower = html.toLowerCase();
|
const lower = html.toLowerCase();
|
||||||
const isFullDoc =
|
const isFullDoc =
|
||||||
lower.startsWith("<html>") || lower.startsWith("<!doctype");
|
lower.startsWith("<html>") || lower.startsWith("<!doctype");
|
||||||
if (isFullDoc || !fragment) {
|
if (!fragment) {
|
||||||
return decoratePage(html, { headInjection, source });
|
return decoratePage(html, { headInjection, source });
|
||||||
}
|
}
|
||||||
if (source) return decorateFragment(html, { headInjection, source });
|
const fragmentHtml = isFullDoc ? fragmentFromPage(html) : html;
|
||||||
return html;
|
if (source) return decorateFragment(fragmentHtml, { headInjection, source });
|
||||||
|
return fragmentHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One prefix-bound route() helper per structure, shared by every render site.
|
// One prefix-bound route() helper per structure, shared by every render site.
|
||||||
|
|
|
||||||
89
tests/test_inspector_sheepgpt.py
Normal file
89
tests/test_inspector_sheepgpt.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""Browser regression test for Bliss template saves inside an hx-get child page.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
.venv/bin/python tests/test_inspector_sheepgpt.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from playwright.sync_api import Page, Request, sync_playwright
|
||||||
|
|
||||||
|
|
||||||
|
BASE_URL = "https://brb.city"
|
||||||
|
SHEEPGPT_URL = f"{BASE_URL}/sheepgpt/"
|
||||||
|
MESSAGE_RAW_TEMPLATE_ID = "28"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NetworkTrace:
|
||||||
|
requests: list[tuple[str, str]] = field(default_factory=list)
|
||||||
|
|
||||||
|
def record(self, request: Request) -> None:
|
||||||
|
self.requests.append((request.method, urlparse(request.url).path))
|
||||||
|
|
||||||
|
def after(self, start: int) -> list[tuple[str, str]]:
|
||||||
|
return self.requests[start:]
|
||||||
|
|
||||||
|
|
||||||
|
class BlissInspector:
|
||||||
|
"""Small page-object API for the inspector's normal user workflow."""
|
||||||
|
|
||||||
|
def __init__(self, page: Page) -> None:
|
||||||
|
self.page = page
|
||||||
|
|
||||||
|
def open_slideout(self) -> None:
|
||||||
|
self.page.get_by_role("button", name="🔍").click()
|
||||||
|
self.page.locator("#bliss-live-editor").wait_for()
|
||||||
|
|
||||||
|
def open_template(self, name: str, template_id: str) -> None:
|
||||||
|
self.page.get_by_role("button", name=re.compile(rf"^{re.escape(name)}(?: |$)")).click()
|
||||||
|
self.page.locator(
|
||||||
|
f'[data-bliss-artifact-editor][data-kind="template"][data-id="{template_id}"]'
|
||||||
|
).wait_for()
|
||||||
|
|
||||||
|
def save_template(self) -> None:
|
||||||
|
self.page.locator("[data-bliss-artifact-editor] [data-save]").click()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch()
|
||||||
|
page = browser.new_page()
|
||||||
|
trace = NetworkTrace()
|
||||||
|
page.on("request", trace.record)
|
||||||
|
|
||||||
|
page.goto(SHEEPGPT_URL, wait_until="networkidle")
|
||||||
|
inspector = BlissInspector(page)
|
||||||
|
inspector.open_slideout()
|
||||||
|
inspector.open_template("message-raw", MESSAGE_RAW_TEMPLATE_ID)
|
||||||
|
|
||||||
|
chat_before = page.locator("#chat").element_handle()
|
||||||
|
assert chat_before is not None, "the embedded chat did not render"
|
||||||
|
start = len(trace.requests)
|
||||||
|
with page.expect_response(re.compile(r"/_bliss/render-template$")) as rendered:
|
||||||
|
inspector.save_template()
|
||||||
|
render_response = rendered.value
|
||||||
|
print(f"template render response: {render_response.status} {render_response.url}")
|
||||||
|
print(f"template render body: {render_response.request.post_data}")
|
||||||
|
print("requests after message-raw save:")
|
||||||
|
for method, path in trace.after(start):
|
||||||
|
print(f" {method} {path}")
|
||||||
|
assert render_response.status == 200, render_response.text()
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
|
||||||
|
requests = trace.after(start)
|
||||||
|
# Saving a child template must never replay the outer sheepgpt route.
|
||||||
|
assert ("GET", "/sheepgpt/") not in requests, requests
|
||||||
|
# The embedded chat itself must stay mounted; only matching template
|
||||||
|
# instances should be patched.
|
||||||
|
assert page.locator("#chat").element_handle() == chat_before
|
||||||
|
assert page.locator("#bliss-live-editor").is_visible()
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue