From c8e50cb6295413cd716dc3bab6760a8427a2354c Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 6 Aug 2026 17:08:17 -0400 Subject: [PATCH] fix: retain child provenance in htmx page swaps --- .gitignore | 1 + index.js | 29 +++++++++-- tests/test_inspector_sheepgpt.py | 89 ++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 tests/test_inspector_sheepgpt.py diff --git a/.gitignore b/.gitignore index 4923d39..e5dc11d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.venv .DS_Store dbs .env diff --git a/index.js b/index.js index 670d039..0b701cd 100644 --- a/index.js +++ b/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 // document (or any non-htmx request) becomes a decorated page; an htmx partial // 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 isFullDoc = lower.startsWith("") || lower.startsWith(" 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()