From 838d1b07b1c5713824602cc9b53628c955bbbe78 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 28 Feb 2026 19:08:39 -0800 Subject: [PATCH] Fix local HTML file routing in open wrapper (#684) * Route local HTML open targets to cmux browser * Keep file:// omnibar navigation inside cmux browser * Load local file URLs via WKWebView file API * Add browser regression test for local file URL loads * Address PR feedback on local HTML and file URL handling --- Resources/bin/open | 204 ++++++++++++++++-- Sources/Panels/BrowserPanel.swift | 33 ++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 54 +++++ tests/test_open_wrapper.py | 164 ++++++++++++++ tests_v2/test_browser_file_url_load.py | 93 ++++++++ 5 files changed, 533 insertions(+), 15 deletions(-) create mode 100644 tests_v2/test_browser_file_url_load.py diff --git a/Resources/bin/open b/Resources/bin/open index 9c81ea54..0b0ab639 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -105,6 +105,169 @@ is_http_url() { return 1 } +is_file_url() { + local value="$1" + case "$value" in + [Ff][Ii][Ll][Ee]://*) + return 0 + ;; + esac + return 1 +} + +has_uri_scheme() { + local value="$1" + [[ "$value" =~ ^[A-Za-z][A-Za-z0-9+.-]*: ]] +} + +is_html_extension() { + local value + value="$(to_lower_ascii "$(trim "$1")")" + case "$value" in + *.html|*.htm) + return 0 + ;; + esac + return 1 +} + +is_explicit_local_path() { + local value="$1" + case "$value" in + /*|./*|../*|~|~/*) + return 0 + ;; + esac + return 1 +} + +file_url_points_to_html() { + local value="$1" + if [[ -n "$PYTHON3_BIN" ]]; then + "$PYTHON3_BIN" - "$value" <<'PY' >/dev/null 2>&1 +import sys +from urllib.parse import unquote, urlsplit + +value = sys.argv[1].strip() +if not value: + raise SystemExit(1) + +parts = urlsplit(value) +path = unquote(parts.path or "") +lower = path.lower() +if lower.endswith(".html") or lower.endswith(".htm"): + raise SystemExit(0) +raise SystemExit(1) +PY + return $? + fi + + local without_fragment="${value%%\#*}" + local without_query="${without_fragment%%\?*}" + local remainder path_part + + case "$without_query" in + [Ff][Ii][Ll][Ee]://*) + remainder="${without_query#*://}" + ;; + *) + return 1 + ;; + esac + + if [[ "$remainder" == /* ]]; then + path_part="$remainder" + elif [[ "$remainder" == */* ]]; then + path_part="/${remainder#*/}" + else + return 1 + fi + + is_html_extension "$path_part" +} + +path_to_file_url_without_python() { + local raw="$1" + local expanded="$raw" + case "$expanded" in + "~") + expanded="$HOME" + ;; + "~/"*) + expanded="$HOME/${expanded#~/}" + ;; + esac + + local absolute + if [[ "$expanded" == /* ]]; then + absolute="$expanded" + else + absolute="$(pwd)/$expanded" + fi + + local directory="$absolute" + local basename="" + if [[ "$absolute" == */* ]]; then + directory="${absolute%/*}" + basename="${absolute##*/}" + fi + + local resolved_directory + if resolved_directory="$(cd "$directory" 2>/dev/null && pwd -P)"; then + absolute="$resolved_directory" + if [[ -n "$basename" ]]; then + absolute="$absolute/$basename" + fi + fi + + local encoded="" + local length=${#absolute} + local index char hex + local LC_ALL=C + for ((index = 0; index < length; index++)); do + char="${absolute:index:1}" + case "$char" in + [a-zA-Z0-9.~_-]|/) + encoded+="$char" + ;; + *) + printf -v hex '%02X' "'$char" + encoded+="%$hex" + ;; + esac + done + printf 'file://%s\n' "$encoded" +} + +path_to_file_url() { + local raw="$1" + if [[ -n "$PYTHON3_BIN" ]]; then + local converted + if converted="$("$PYTHON3_BIN" - "$raw" <<'PY' 2>/dev/null +import pathlib +import sys + +raw = sys.argv[1] +if not raw: + raise SystemExit(1) + +path = pathlib.Path(raw).expanduser() +if path.is_absolute(): + resolved = path.resolve(strict=False) +else: + resolved = (pathlib.Path.cwd() / path).resolve(strict=False) + +sys.stdout.write(resolved.as_uri()) +PY + )"; then + printf '%s\n' "$converted" + return 0 + fi + fi + + path_to_file_url_without_python "$raw" +} + normalize_host() { local value value="$(trim "$1")" @@ -212,9 +375,10 @@ if [[ $# -eq 0 ]]; then fi # Scan for flags that indicate explicit user intent → pass through. -# Also collect non-flag arguments (potential URLs/files). +# Also collect non-flag arguments and route eligible browser targets to cmux. passthrough=false -urls=() +cmux_targets=() +passthrough_args=() for arg in "$@"; do case "$arg" in -a|-b|-R|-e|-t|-f|-W|-g|-n|-h|-s|-j|-u|--env|--stdin|--stdout|--stderr) @@ -228,17 +392,33 @@ for arg in "$@"; do ;; *) if is_http_url "$arg"; then - urls+=("$arg") + cmux_targets+=("$arg") + elif is_file_url "$arg"; then + if file_url_points_to_html "$arg"; then + cmux_targets+=("$arg") + else + passthrough_args+=("$arg") + fi + elif has_uri_scheme "$arg"; then + passthrough_args+=("$arg") + elif is_html_extension "$arg"; then + if is_explicit_local_path "$arg" || [[ -e "$arg" ]]; then + if local_file_url="$(path_to_file_url "$arg")"; then + cmux_targets+=("$local_file_url") + else + passthrough_args+=("$arg") + fi + else + passthrough_args+=("$arg") + fi else - # Non-URL, non-flag argument (file path, etc.) → pass through all - passthrough=true - break + passthrough_args+=("$arg") fi ;; esac done -if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then +if [[ "$passthrough" == true ]] || [[ ${#cmux_targets[@]} -eq 0 ]]; then system_open "$@" fi @@ -269,15 +449,15 @@ fi # Open each URL in cmux's in-app browser; track failures individually. failed_urls=() -for url in "${urls[@]}"; do - if ! host_matches_whitelist "$url"; then +for url in "${cmux_targets[@]}"; do + if is_http_url "$url" && ! host_matches_whitelist "$url"; then failed_urls+=("$url") continue fi "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url") done -# Fall back to system open only for URLs that failed. -if [[ ${#failed_urls[@]} -gt 0 ]]; then - system_open "${failed_urls[@]}" +# Fall back to system open for unmatched args and URLs that failed. +if [[ ${#passthrough_args[@]} -gt 0 ]] || [[ ${#failed_urls[@]} -gt 0 ]]; then + system_open "${passthrough_args[@]}" "${failed_urls[@]}" fi diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9188d093..f91576d0 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -394,11 +394,35 @@ func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest { return preparedRequest } +func browserReadAccessURL(forLocalFileURL fileURL: URL, fileManager: FileManager = .default) -> URL? { + guard fileURL.isFileURL, fileURL.path.hasPrefix("/") else { return nil } + let path = fileURL.path + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue { + return fileURL + } + + let parent = fileURL.deletingLastPathComponent() + guard !parent.path.isEmpty, parent.path.hasPrefix("/") else { return nil } + return parent +} + +@discardableResult +func browserLoadRequest(_ request: URLRequest, in webView: WKWebView) -> WKNavigation? { + guard let url = request.url else { return nil } + if url.isFileURL { + guard let readAccessURL = browserReadAccessURL(forLocalFileURL: url) else { return nil } + return webView.loadFileURL(url, allowingReadAccessTo: readAccessURL) + } + return webView.load(browserPreparedNavigationRequest(request)) +} + private let browserEmbeddedNavigationSchemes: Set = [ "about", "applewebdata", "blob", "data", + "file", "http", "https", "javascript", @@ -1901,7 +1925,7 @@ final class BrowserPanel: Panel, ObservableObject { BrowserHistoryStore.shared.recordTypedNavigation(url: url) } navigationDelegate?.lastAttemptedURL = url - webView.load(browserPreparedNavigationRequest(request)) + browserLoadRequest(request, in: webView) } /// Navigate with smart URL/search detection @@ -2047,6 +2071,9 @@ func resolveBrowserNavigableURL(_ input: String) -> URL? { if scheme == "http" || scheme == "https" { return url } + if scheme == "file", url.isFileURL, url.path.hasPrefix("/") { + return url + } return nil } @@ -3125,7 +3152,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { let targetURL = navigationAction.request.url?.absoluteString ?? "nil" dlog("browser.nav.decidePolicy.action kind=loadInPlaceFromNilTarget url=\(targetURL)") #endif - webView.load(navigationAction.request) + browserLoadRequest(navigationAction.request, in: webView) decisionHandler(.cancel) return } @@ -3288,7 +3315,7 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { #if DEBUG dlog("browser.nav.createWebView.action kind=loadInPlace url=\(url.absoluteString)") #endif - webView.load(navigationAction.request) + browserLoadRequest(navigationAction.request, in: webView) } } return nil diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 08063767..3a90c8a1 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -7708,6 +7708,58 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase { } } +final class BrowserNavigableURLResolutionTests: XCTestCase { + func testResolvesFileSchemeAsNavigableURL() throws { + let resolved = try XCTUnwrap(resolveBrowserNavigableURL("file:///tmp/cmux-local-test.html")) + XCTAssertTrue(resolved.isFileURL) + XCTAssertEqual(resolved.path, "/tmp/cmux-local-test.html") + } + + func testRejectsNonWebNonFileScheme() { + XCTAssertNil(resolveBrowserNavigableURL("mailto:test@example.com")) + XCTAssertNil(resolveBrowserNavigableURL("ftp://example.com/file.html")) + } + + func testRejectsHostOnlyFileURL() { + XCTAssertNil(resolveBrowserNavigableURL("file://example.html")) + } +} + +final class BrowserReadAccessURLTests: XCTestCase { + func testUsesParentDirectoryForFileURL() throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true) + let file = dir.appendingPathComponent("sample.html") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + try "".write(to: file, atomically: true, encoding: .utf8) + + let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: file)) + XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL) + } + + func testUsesDirectoryURLWhenTargetIsDirectory() throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: dir)) + XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL) + } + + func testUsesParentDirectoryWhenFileDoesNotExist() throws { + let missing = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).html") + let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: missing)) + XCTAssertEqual(readAccessURL.standardizedFileURL, missing.deletingLastPathComponent().standardizedFileURL) + } + + func testReturnsNilForHostOnlyFileURL() throws { + let hostOnly = try XCTUnwrap(URL(string: "file://example.html")) + XCTAssertNil(browserReadAccessURL(forLocalFileURL: hostOnly)) + } +} + final class BrowserExternalNavigationSchemeTests: XCTestCase { func testCustomAppSchemesOpenExternally() throws { let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc")) @@ -7726,6 +7778,7 @@ final class BrowserExternalNavigationSchemeTests: XCTestCase { let http = try XCTUnwrap(URL(string: "http://example.com")) let about = try XCTUnwrap(URL(string: "about:blank")) let data = try XCTUnwrap(URL(string: "data:text/plain,hello")) + let file = try XCTUnwrap(URL(string: "file:///tmp/cmux-local-test.html")) let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000")) let javascript = try XCTUnwrap(URL(string: "javascript:void(0)")) let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page")) @@ -7734,6 +7787,7 @@ final class BrowserExternalNavigationSchemeTests: XCTestCase { XCTAssertFalse(browserShouldOpenURLExternally(http)) XCTAssertFalse(browserShouldOpenURLExternally(about)) XCTAssertFalse(browserShouldOpenURLExternally(data)) + XCTAssertFalse(browserShouldOpenURLExternally(file)) XCTAssertFalse(browserShouldOpenURLExternally(blob)) XCTAssertFalse(browserShouldOpenURLExternally(javascript)) XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal)) diff --git a/tests/test_open_wrapper.py b/tests/test_open_wrapper.py index 6119033a..e54f134f 100755 --- a/tests/test_open_wrapper.py +++ b/tests/test_open_wrapper.py @@ -34,6 +34,8 @@ def run_wrapper( legacy_open_setting: str | None = None, whitelist: str | None, fail_urls: list[str] | None = None, + local_files: list[str] | None = None, + python_bin: str | None = None, ) -> tuple[list[str], list[str], int, str]: with tempfile.TemporaryDirectory(prefix="cmux-open-wrapper-test-") as td: tmp = Path(td) @@ -113,6 +115,12 @@ exit 0 """, ) + if local_files: + for relative_path in local_files: + target = tmp / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("fixture", encoding="utf-8") + env = os.environ.copy() env["CMUX_SOCKET_PATH"] = "/tmp/cmux-open-wrapper-test.sock" env["CMUX_BUNDLE_ID"] = "com.cmuxterm.app.debug.test" @@ -120,6 +128,10 @@ exit 0 env["CMUX_OPEN_WRAPPER_DEFAULTS"] = str(defaults) env["FAKE_OPEN_LOG"] = str(open_log) env["FAKE_CMUX_LOG"] = str(cmux_log) + if python_bin is None: + env.pop("CMUX_OPEN_WRAPPER_PYTHON3", None) + else: + env["CMUX_OPEN_WRAPPER_PYTHON3"] = python_bin if intercept_setting is None: env.pop("FAKE_DEFAULTS_INTERCEPT_OPEN", None) @@ -143,6 +155,7 @@ exit 0 result = subprocess.run( ["/bin/bash", str(wrapper), *args], + cwd=tmp, env=env, capture_output=True, text=True, @@ -282,6 +295,149 @@ def test_uppercase_scheme_routes_to_cmux(failures: list[str]) -> None: expect(cmux_log == [f"browser open {url}"], f"uppercase scheme: unexpected cmux log {cmux_log}", failures) +def test_local_html_file_routes_to_cmux(failures: list[str]) -> None: + filename = "fixtures/hello page.HTML" + open_log, cmux_log, code, stderr = run_wrapper( + args=[filename], + intercept_setting="1", + whitelist="", + local_files=[filename], + ) + expect(code == 0, f"local html file: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"local html file: system open should not be called, got {open_log}", failures) + expect(len(cmux_log) == 1, f"local html file: expected exactly one cmux call, got {cmux_log}", failures) + if cmux_log: + expect( + cmux_log[0].startswith("browser open file://"), + f"local html file: expected file:// target, got {cmux_log[0]}", + failures, + ) + expect( + "hello%20page.HTML" in cmux_log[0], + f"local html file: expected URL-encoded filename in cmux target, got {cmux_log[0]}", + failures, + ) + + +def test_file_url_html_routes_to_cmux(failures: list[str]) -> None: + url = "file:///tmp/cmux-open-wrapper-fixture.html" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + ) + expect(code == 0, f"file url html: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"file url html: system open should not be called, got {open_log}", failures) + expect(cmux_log == [f"browser open {url}"], f"file url html: unexpected cmux log {cmux_log}", failures) + + +def test_file_url_html_routes_to_cmux_without_python_binary(failures: list[str]) -> None: + url = "file:///tmp/cmux-open-wrapper-fixture.html" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + python_bin="/definitely/missing/python3", + ) + expect(code == 0, f"file url html no-python fallback: wrapper exited {code}: {stderr}", failures) + expect( + open_log == [], + f"file url html no-python fallback: system open should not be called, got {open_log}", + failures, + ) + expect( + cmux_log == [f"browser open {url}"], + f"file url html no-python fallback: unexpected cmux log {cmux_log}", + failures, + ) + + +def test_local_html_file_routes_to_cmux_without_python_binary(failures: list[str]) -> None: + filename = "fixtures/no python fallback.html" + open_log, cmux_log, code, stderr = run_wrapper( + args=[filename], + intercept_setting="1", + whitelist="", + local_files=[filename], + python_bin="/definitely/missing/python3", + ) + expect(code == 0, f"local html no-python fallback: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"local html no-python fallback: system open should not be called, got {open_log}", failures) + expect( + len(cmux_log) == 1, + f"local html no-python fallback: expected exactly one cmux call, got {cmux_log}", + failures, + ) + if cmux_log: + expect( + cmux_log[0].startswith("browser open file://"), + f"local html no-python fallback: expected file:// target, got {cmux_log[0]}", + failures, + ) + expect( + "no%20python%20fallback.html" in cmux_log[0], + f"local html no-python fallback: expected URL-encoded filename, got {cmux_log[0]}", + failures, + ) + + +def test_domain_like_html_argument_passthrough(failures: list[str]) -> None: + arg = "example.com/report.html" + open_log, cmux_log, code, stderr = run_wrapper( + args=[arg], + intercept_setting="1", + whitelist="", + ) + expect(code == 0, f"domain-like html argument: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [], + f"domain-like html argument: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [arg], + f"domain-like html argument: expected system open [{arg}], got {open_log}", + failures, + ) + + +def test_non_file_scheme_html_passthrough(failures: list[str]) -> None: + url = "ftp://example.com/report.html" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + ) + expect(code == 0, f"non-file scheme html: wrapper exited {code}: {stderr}", failures) + expect(cmux_log == [], f"non-file scheme html: cmux should not be called, got {cmux_log}", failures) + expect(open_log == [url], f"non-file scheme html: expected system open [{url}], got {open_log}", failures) + + +def test_mailto_html_passthrough(failures: list[str]) -> None: + url = "mailto:help@example.com?subject=report.html" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + ) + expect(code == 0, f"mailto html: wrapper exited {code}: {stderr}", failures) + expect(cmux_log == [], f"mailto html: cmux should not be called, got {cmux_log}", failures) + expect(open_log == [url], f"mailto html: expected system open [{url}], got {open_log}", failures) + + +def test_local_non_html_file_passthrough(failures: list[str]) -> None: + filename = "fixtures/readme.md" + open_log, cmux_log, code, stderr = run_wrapper( + args=[filename], + intercept_setting="1", + whitelist="", + local_files=[filename], + ) + expect(code == 0, f"local non-html file: wrapper exited {code}: {stderr}", failures) + expect(cmux_log == [], f"local non-html file: cmux should not be called, got {cmux_log}", failures) + expect(open_log == [filename], f"local non-html file: expected system open [{filename}], got {open_log}", failures) + + def test_unicode_whitelist_matches_punycode_url(failures: list[str]) -> None: url = "https://xn--bcher-kva.example/path" open_log, cmux_log, code, stderr = run_wrapper( @@ -316,6 +472,14 @@ def main() -> int: test_legacy_toggle_fallback_passthrough(failures) test_legacy_toggle_fallback_case_insensitive_passthrough(failures) test_uppercase_scheme_routes_to_cmux(failures) + test_local_html_file_routes_to_cmux(failures) + test_file_url_html_routes_to_cmux(failures) + test_file_url_html_routes_to_cmux_without_python_binary(failures) + test_local_html_file_routes_to_cmux_without_python_binary(failures) + test_domain_like_html_argument_passthrough(failures) + test_non_file_scheme_html_passthrough(failures) + test_mailto_html_passthrough(failures) + test_local_non_html_file_passthrough(failures) test_unicode_whitelist_matches_punycode_url(failures) test_punycode_whitelist_matches_unicode_url(failures) diff --git a/tests_v2/test_browser_file_url_load.py b/tests_v2/test_browser_file_url_load.py new file mode 100644 index 00000000..a4c63110 --- /dev/null +++ b/tests_v2/test_browser_file_url_load.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""v2 regression: browser can render local file:// HTML pages.""" + +import os +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_until(pred, timeout_s: float, label: str) -> None: + deadline = time.time() + timeout_s + last_exc = None + while time.time() < deadline: + try: + if pred(): + return + except Exception as exc: # noqa: BLE001 + last_exc = exc + time.sleep(0.05) + if last_exc is not None: + raise cmuxError(f"Timed out waiting for {label}: {last_exc}") + raise cmuxError(f"Timed out waiting for {label}") + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="cmux-file-url-") as root: + html_path = Path(root) / "local-test.html" + html_path.write_text( + """ + + + cmux file url load + +

local HTML file loaded

+

This page is loaded via file://

+ + +""".strip(), + encoding="utf-8", + ) + file_url = html_path.resolve().as_uri() + + with cmux(SOCKET_PATH) as c: + opened = c._call("browser.open_split", {"url": "about:blank"}) or {} + sid = str(opened.get("surface_id") or "") + _must(bool(sid), f"browser.open_split returned no surface_id: {opened}") + + c._call("browser.navigate", {"surface_id": sid, "url": file_url}) + + _wait_until( + lambda: str((c._call("browser.get.title", {"surface_id": sid}) or {}).get("title") or "") + == "cmux file url load", + timeout_s=5.0, + label="browser.get.title(file://)", + ) + + page_text = c._call( + "browser.eval", + { + "surface_id": sid, + "script": "document.body ? (document.body.innerText || '') : ''", + }, + ) or {} + _must( + "local HTML file loaded" in str(page_text.get("value") or ""), + f"Expected file:// page body text: {page_text}", + ) + + url_payload = c._call("browser.url.get", {"surface_id": sid}) or {} + actual_url = str(url_payload.get("url") or "") + _must( + actual_url.startswith("file://"), + f"Expected browser.url.get to stay on file:// URL: {url_payload}", + ) + + print("PASS: browser loads local file:// HTML") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())