From 10c7d1048ff019274017ce466e8227869e12510d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 23:53:04 +0000 Subject: [PATCH 1/7] Report debug script syntax errors as the run's error, immediately A debug script that failed to compile never ran its wrapper, so the run waited out its whole timeout and the only clue was an app-level javascript-error event - which WebKit sanitizes in sandboxed frames to "Script error. (details withheld ...)". The bridge now checks whether the injected script started. If it did not, the error event raised during insertion becomes the run's result: the parser message plus line/column within the debug script on Chromium and Firefox, or an explanation that details were withheld on WebKit. The run finishes straight away instead of timing out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VX4speFtsjrJCtLtVaFB85 --- datasette_apps/rendering.py | 62 ++++++++++++++++++++++++++++++++++++- tests/test_playwright.py | 41 ++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/datasette_apps/rendering.py b/datasette_apps/rendering.py index 03a18da..b00c559 100644 --- a/datasette_apps/rendering.py +++ b/datasette_apps/rendering.py @@ -74,6 +74,38 @@ def _csp_meta(csp): return true; } + function debugScriptCompileError(details, code) { + if (!details) { + return {message: "Debug script did not run"}; + } + if (details.sanitized) { + return { + sanitized: true, + message: "Debug script failed to compile, most likely a syntax " + + "error - this browser withholds the details in sandboxed " + + "frames. Check for unbalanced brackets or quotes, and for " + + "import or export statements, which an async function body " + + "cannot contain" + }; + } + var error = { + name: details.name || "SyntaxError", + // Chromium prefixes the message with the DOM call that inserted + // the script + message: "Debug script failed to compile: " + + String(details.message || "").replace( + /^Failed to execute '[^']*' on '[^']*': /, "" + ) + }; + // The debug script starts on line 2 of the injected wrapper + var line = details.lineno - 1; + if (line >= 1 && line <= code.split("\\n").length) { + error.line = line; + error.column = details.colno; + } + return error; + } + function runDebugScript(id, code) { function report(ok, payload) { var message = {type: "datasette-app-debug-result", id: id, ok: ok}; @@ -116,16 +148,40 @@ def _csp_meta(csp): delete window.__datasetteAppsDebugReportError; report(false, normalizeError(error)); }; + var started = false; + var compileError = null; + window.__datasetteAppsDebugStarted = function() { + started = true; + }; var script = document.createElement("script"); script.textContent = "(async function() {" + + "window.__datasetteAppsDebugStarted(); " + "try { window.__datasetteAppsDebugReport(" + "await (async function() {\\n" + code + "\\n})()); }" + " catch (error) { window.__datasetteAppsDebugReportError(error); }" + "})();"; - (document.body || document.documentElement).appendChild(script); + // Inline scripts run synchronously on insertion and the async + // wrapper cannot throw, so an error event raised meanwhile means the + // script failed to compile: it becomes the run's result, not an app + // error - otherwise the run would wait out its whole timeout. + debugErrorHandler = function(details) { + compileError = details; + return true; + }; + try { + (document.body || document.documentElement).appendChild(script); + } finally { + debugErrorHandler = null; + } if (script.parentNode) { script.parentNode.removeChild(script); } + delete window.__datasetteAppsDebugStarted; + if (!started) { + delete window.__datasetteAppsDebugReport; + delete window.__datasetteAppsDebugReportError; + report(false, debugScriptCompileError(compileError, code)); + } } debugMessageHandler = function(message) { @@ -150,6 +206,7 @@ def iframe_bridge_script(channel_token=None, debug=False): var lastViewportContent = null; var viewportReporterStarted = false; var debugMessageHandler = null; + var debugErrorHandler = null; function noopHistoryMethod() { } @@ -380,6 +437,9 @@ def iframe_bridge_script(channel_token=None, debug=False): details.filename = event.filename || ""; details.lineno = event.lineno || 0; details.colno = event.colno || 0; + if (debugErrorHandler && debugErrorHandler(details)) { + return; + } postAppError("javascript-error", details); }, true); diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 0cea3e6..7bac3ff 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1131,3 +1131,44 @@ def test_debug_harness_reports_app_errors_and_unserializable_result( or (error.get("sanitized") and "details withheld" in error["message"]) for error in js_errors ) + + +def test_debug_harness_reports_debug_script_syntax_error_immediately( + tmp_path, monkeypatch +): + monkeypatch.setenv("DATASETTE_SECRET", DEBUG_ACTOR_SECRET) + server = DatasetteServer(tmp_path) + app = asyncio.run( + server.create_app('

Fine app

', name="Fine app") + ) + job, payload, harness = asyncio.run( + _create_debug_job_with_task( + server, + app["id"], + "const title = document.querySelector('#title';\n" + "return title.textContent;", + timeout_ms=30000, + ) + ) + + with server, _browser_page() as page: + _run_debug_task(server, page, "01TASK0000000000000000SYNX", payload, harness) + envelope = _wait_for_task_result(page)[0]["envelope"] + + # A debug script that fails to compile never runs its wrapper, so + # it used to wait out the whole timeout with only an app-level + # javascript-error event - just "Script error." on WebKit. It is + # now the run's own error, reported straight away. + assert envelope["timed_out"] is False + assert envelope["duration_ms"] < 10000 + assert envelope["ok"] is False + error = envelope["error"] + assert error["message"].startswith("Debug script failed to compile") + # Chromium and Firefox report the parser's message and position; + # WebKit withholds them in sandboxed frames, so the message + # explains that instead + if not error.get("sanitized"): + assert error["name"] == "SyntaxError" + assert "Failed to execute" not in error["message"] + assert error["line"] == 1 + assert envelope["events"]["errors"] == [] From c0cfede610417ec80f030dfd5797fc7142f81bb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:01:01 +0000 Subject: [PATCH 2/7] Accept "private, no-store" cache-control in debug frame test Newer Datasette releases add "private" to responses for signed-in actors. Check for the no-store directive instead of the exact header so the test passes against older and newer Datasette alike. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VX4speFtsjrJCtLtVaFB85 --- tests/test_debug_jobs.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_debug_jobs.py b/tests/test_debug_jobs.py index 23d9651..c9a815d 100644 --- a/tests/test_debug_jobs.py +++ b/tests/test_debug_jobs.py @@ -149,7 +149,13 @@ async def test_frame_serves_job_revision_with_debug_bridge(): assert "datasette-app-debug-eval" in document assert "waitFor" in document assert job["config"]["channel_token"] in document - assert response.headers["cache-control"] == "no-store" + # Newer Datasette releases add "private" to responses for signed-in + # actors, so check for the directive rather than the exact header + cache_control = [ + directive.strip() + for directive in response.headers["cache-control"].lower().split(",") + ] + assert "no-store" in cache_control # Only the actor who created the job may fetch the document; the # frame token additionally binds the request to the claimed run. From dffc7e29a45288ddd1863e1f5eb03b9d0c5f7d35 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:01:01 +0000 Subject: [PATCH 3/7] TEMPORARY: probe error sanitization per browser engine Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VX4speFtsjrJCtLtVaFB85 --- tests/test_playwright.py | 86 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 7bac3ff..9aaf826 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1172,3 +1172,89 @@ def test_debug_harness_reports_debug_script_syntax_error_immediately( assert "Failed to execute" not in error["message"] assert error["line"] == 1 assert envelope["events"]["errors"] == [] + + +# TEMPORARY PROBE - records what each engine reports to a sandboxed +# srcdoc frame's error listener for several ways of raising an error. +# Emitted as a warning so CI logs carry the data; removed after. +_PROBES = { + "classic_toplevel": '', + "classic_sourceurl_data": '', + "classic_sourceurl_relative": '', + "module_toplevel": '', + "classic_timeout": '', + "module_timeout": '', + "classic_rethrown_by_module": ( + '" + '' + ), + "classic_rethrown_by_classic": ( + '' + ), + "classic_listener_dispatch": ( + '' + ), + "classic_syntax": '', +} + +_PROBE_LISTENER = """""" + + +def test_zz_probe_error_sanitization(tmp_path): + import warnings + + from datasette_apps.csp import build_csp + from datasette_apps.rendering import _csp_meta + + server = DatasetteServer(tmp_path) + docs = { + name: "" + + _csp_meta(build_csp([])) + + _PROBE_LISTENER.replace("__NAME__", json.dumps(name)) + + html + for name, html in _PROBES.items() + } + with server, _browser_page() as page: + page.goto(server.url + "/") + results = page.evaluate( + """async (docs) => { + const results = {}; + window.addEventListener("message", (e) => { + if (e.data && e.data.probe) { + (results[e.data.probe] = results[e.data.probe] || []).push(e.data); + } + }); + for (const [name, doc] of Object.entries(docs)) { + const iframe = document.createElement("iframe"); + iframe.setAttribute("sandbox", "allow-scripts allow-forms"); + iframe.srcdoc = doc; + document.body.appendChild(iframe); + } + await new Promise((r) => setTimeout(r, 2000)); + return results; + }""", + docs, + ) + browser = os.environ.get("DATASETTE_APPS_PLAYWRIGHT_BROWSER", "chromium") + warnings.warn( + "PROBE-RESULTS " + browser + " " + json.dumps(results, sort_keys=True) + ) From 3f6e42524721dd836ae89c61b4c57e36bf687927 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:05:52 +0000 Subject: [PATCH 4/7] TEMPORARY: probe round 2 - blob/data script conversion and callback wrapping Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VX4speFtsjrJCtLtVaFB85 --- tests/test_playwright.py | 119 ++++++++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 46 deletions(-) diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 9aaf826..a5a42fb 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1174,65 +1174,94 @@ def test_debug_harness_reports_debug_script_syntax_error_immediately( assert envelope["events"]["errors"] == [] -# TEMPORARY PROBE - records what each engine reports to a sandboxed -# srcdoc frame's error listener for several ways of raising an error. -# Emitted as a warning so CI logs carry the data; removed after. -_PROBES = { - "classic_toplevel": '', - "classic_sourceurl_data": '', - "classic_sourceurl_relative": '', - "module_toplevel": '', - "classic_timeout": '', - "module_timeout": '', - "classic_rethrown_by_module": ( - '" - '' - ), - "classic_rethrown_by_classic": ( - '' - ), - "classic_listener_dispatch": ( - '' - ), - "classic_syntax": '', -} +# TEMPORARY PROBE (round 2) - can a MutationObserver convert inline +# classic scripts to same-text blob:/data: scripts before they run, and +# does WebKit then report their errors in full? Also checks a +# Sentry-style callback wrapper. Emitted as a warning; removed after. +_PROBE2_CONVERTER = """""" + +_PROBE2_BODY = """ + + +""" + +_PROBE2_WRAPPER = """ +""" -_PROBE_LISTENER = """""" +def _probe2_doc(name, script_src_extra, body): + csp = ( + "default-src 'none'; script-src 'unsafe-inline'" + + script_src_extra + + "; style-src 'unsafe-inline'; img-src data: blob:;" + ) + return ( + "" + + '' + + _PROBE2_LISTENER.replace("__NAME__", json.dumps(name)) + + body.replace("__NAME__", json.dumps(name)) + ) + + def test_zz_probe_error_sanitization(tmp_path): import warnings - from datasette_apps.csp import build_csp - from datasette_apps.rendering import _csp_meta + def converter(mode, crossorigin=False): + return _PROBE2_CONVERTER.replace("__MODE__", json.dumps(mode)).replace( + "__CROSSORIGIN__", json.dumps(crossorigin) + ) - server = DatasetteServer(tmp_path) docs = { - name: "" - + _csp_meta(build_csp([])) - + _PROBE_LISTENER.replace("__NAME__", json.dumps(name)) - + html - for name, html in _PROBES.items() + "observer_only": _probe2_doc("observer_only", "", converter("none") + _PROBE2_BODY), + "blob": _probe2_doc("blob", " blob:", converter("blob") + _PROBE2_BODY), + "blob_crossorigin": _probe2_doc( + "blob_crossorigin", " blob:", converter("blob", True) + _PROBE2_BODY + ), + "data": _probe2_doc("data", " data:", converter("data") + _PROBE2_BODY), + "wrapper": _probe2_doc("wrapper", "", _PROBE2_WRAPPER), } + server = DatasetteServer(tmp_path) with server, _browser_page() as page: page.goto(server.url + "/") results = page.evaluate( @@ -1255,6 +1284,4 @@ def test_zz_probe_error_sanitization(tmp_path): docs, ) browser = os.environ.get("DATASETTE_APPS_PLAYWRIGHT_BROWSER", "chromium") - warnings.warn( - "PROBE-RESULTS " + browser + " " + json.dumps(results, sort_keys=True) - ) + warnings.warn("PROBE-RESULTS " + browser + " " + json.dumps(results, sort_keys=True)) From b4b30fbe1e52ca726fa99bfd9764eaaca843a6ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:20:33 +0000 Subject: [PATCH 5/7] Remove temporary error sanitization probes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VX4speFtsjrJCtLtVaFB85 --- tests/test_playwright.py | 113 --------------------------------------- 1 file changed, 113 deletions(-) diff --git a/tests/test_playwright.py b/tests/test_playwright.py index a5a42fb..7bac3ff 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1172,116 +1172,3 @@ def test_debug_harness_reports_debug_script_syntax_error_immediately( assert "Failed to execute" not in error["message"] assert error["line"] == 1 assert envelope["events"]["errors"] == [] - - -# TEMPORARY PROBE (round 2) - can a MutationObserver convert inline -# classic scripts to same-text blob:/data: scripts before they run, and -# does WebKit then report their errors in full? Also checks a -# Sentry-style callback wrapper. Emitted as a warning; removed after. -_PROBE2_CONVERTER = """""" - -_PROBE2_BODY = """ - - -""" - -_PROBE2_WRAPPER = """ -""" - -_PROBE2_LISTENER = """""" - - -def _probe2_doc(name, script_src_extra, body): - csp = ( - "default-src 'none'; script-src 'unsafe-inline'" - + script_src_extra - + "; style-src 'unsafe-inline'; img-src data: blob:;" - ) - return ( - "" - + '' - + _PROBE2_LISTENER.replace("__NAME__", json.dumps(name)) - + body.replace("__NAME__", json.dumps(name)) - ) - - -def test_zz_probe_error_sanitization(tmp_path): - import warnings - - def converter(mode, crossorigin=False): - return _PROBE2_CONVERTER.replace("__MODE__", json.dumps(mode)).replace( - "__CROSSORIGIN__", json.dumps(crossorigin) - ) - - docs = { - "observer_only": _probe2_doc("observer_only", "", converter("none") + _PROBE2_BODY), - "blob": _probe2_doc("blob", " blob:", converter("blob") + _PROBE2_BODY), - "blob_crossorigin": _probe2_doc( - "blob_crossorigin", " blob:", converter("blob", True) + _PROBE2_BODY - ), - "data": _probe2_doc("data", " data:", converter("data") + _PROBE2_BODY), - "wrapper": _probe2_doc("wrapper", "", _PROBE2_WRAPPER), - } - server = DatasetteServer(tmp_path) - with server, _browser_page() as page: - page.goto(server.url + "/") - results = page.evaluate( - """async (docs) => { - const results = {}; - window.addEventListener("message", (e) => { - if (e.data && e.data.probe) { - (results[e.data.probe] = results[e.data.probe] || []).push(e.data); - } - }); - for (const [name, doc] of Object.entries(docs)) { - const iframe = document.createElement("iframe"); - iframe.setAttribute("sandbox", "allow-scripts allow-forms"); - iframe.srcdoc = doc; - document.body.appendChild(iframe); - } - await new Promise((r) => setTimeout(r, 2000)); - return results; - }""", - docs, - ) - browser = os.environ.get("DATASETTE_APPS_PLAYWRIGHT_BROWSER", "chromium") - warnings.warn("PROBE-RESULTS " + browser + " " + json.dumps(results, sort_keys=True)) From d8b27da42de511c1e32cf4ee016461c9a78e3003 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:20:34 +0000 Subject: [PATCH 6/7] Recover uncaught error details in debug frames on WebKit WebKit reports every uncaught error from a classic script in a sandboxed (opaque origin) frame as a bare "Script error.", so app_debug runs in Safari told the agent nothing about the app's own errors. Probing WebKit in CI showed module scripts and same-origin external scripts keep their error details, and that a MutationObserver sees each parser-inserted script before it runs. On engines where a probe error comes back sanitized, the debug bridge now: - swaps each inline classic +""", + name="Error sampler", + ) + ) + job, payload, harness = asyncio.run( + _create_debug_job_with_task( + server, + app["id"], + """ +await debug.waitFor(() => document.getElementById("title").dataset.ready); +document.getElementById("listener-button").click(); +document.getElementById("attribute-button").click(); +await new Promise((resolve) => setTimeout(resolve, 200)); +return {count: shared.count}; +""", + ) + ) + + with server, _browser_page() as page: + _run_debug_task(server, page, "01TASK0000000000000000RCVR", payload, harness) + envelope = _wait_for_task_result(page)[0]["envelope"] + + # The app ran exactly as it would without the debug bridge: its + # scripts share top-level bindings with each other and with the + # debug script + assert envelope["ok"] is True, envelope + assert envelope["result"] == {"count": 3} + js_errors = { + error["message"]: error + for error in envelope["events"]["errors"] + if error["kind"] == "javascript-error" + } + # WebKit sanitizes every one of these to "Script error." in a + # sandboxed frame; debug frames recover each message + for expected in ( + "top-level code failed", + "click listener failed", + "inline attribute handler failed", + "timer callback failed", + ): + assert any(expected in message for message in js_errors), js_errors + assert not any("Script error" in message for message in js_errors), js_errors + top_level = next( + error + for message, error in js_errors.items() + if "top-level code failed" in message + ) + if top_level["filename"].startswith("app-script-"): + # Engines that sanitize run inline scripts as blob: scripts, + # reported against the app's Nth