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
This commit is contained in:
parent
2e2e190bf4
commit
838d1b07b1
5 changed files with 533 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String> = [
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -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 "<html></html>".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))
|
||||
|
|
|
|||
|
|
@ -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("<!doctype html><title>fixture</title>", 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)
|
||||
|
||||
|
|
|
|||
93
tests_v2/test_browser_file_url_load.py
Normal file
93
tests_v2/test_browser_file_url_load.py
Normal file
|
|
@ -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(
|
||||
"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset=\"utf-8\"><title>cmux file url load</title></head>
|
||||
<body>
|
||||
<h1 id=\"headline\">local HTML file loaded</h1>
|
||||
<p id=\"path\">This page is loaded via file://</p>
|
||||
</body>
|
||||
</html>
|
||||
""".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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue