| author | |
| committer | |
| log | bde0a4c0dd2d420a5fd71f39448d5db1bab078da |
| tree | fba85221e6da92e8a4bab189c915ffb5dc6eba16 |
| parent | 54f167e2e0b857a60946590bfa983fb93b4f138a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>3 files changed, 208 insertions(+), 55 deletions(-)
apps/web/src/browser/browserTargetResolver.test.ts+104-2| ... | @@ -25,6 +25,89 @@ describe("browser target resolver", () => { | ... | @@ -25,6 +25,89 @@ describe("browser target resolver", () => { |
| 25 | }); | 25 | }); |
| 26 | }); | 26 | }); |
| 27 | 27 | ||
| 28 | it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { | ||
| 29 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); | ||
| 30 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | ||
| 31 | expect( | ||
| 32 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | ||
| 33 | kind: "url", | ||
| 34 | url: "http://localhost:5173/dashboard?mode=test#results", | ||
| 35 | }), | ||
| 36 | ).toEqual({ | ||
| 37 | requestedUrl: "http://localhost:5173/dashboard?mode=test#results", | ||
| 38 | resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", | ||
| 39 | resolutionKind: "direct-private-network", | ||
| 40 | environmentId: "environment-1", | ||
| 41 | }); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("preserves URL credentials when mapping localhost onto a remote host", async () => { | ||
| 45 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); | ||
| 46 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | ||
| 47 | expect( | ||
| 48 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | ||
| 49 | kind: "url", | ||
| 50 | url: "http://user:p%40ss@localhost:5173/dashboard", | ||
| 51 | }).resolvedUrl, | ||
| 52 | ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); | ||
| 53 | }); | ||
| 54 | |||
| 55 | it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { | ||
| 56 | readPreparedConnection.mockReturnValue({ | ||
| 57 | httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", | ||
| 58 | }); | ||
| 59 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | ||
| 60 | expect( | ||
| 61 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | ||
| 62 | kind: "url", | ||
| 63 | url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", | ||
| 64 | }).resolvedUrl, | ||
| 65 | ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("maps schemeless localhost navigation onto a remote environment host", async () => { | ||
| 69 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); | ||
| 70 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | ||
| 71 | expect( | ||
| 72 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | ||
| 73 | kind: "url", | ||
| 74 | url: "localhost:3000/app", | ||
| 75 | }).resolvedUrl, | ||
| 76 | ).toBe("http://192.168.1.25:3000/app"); | ||
| 77 | }); | ||
| 78 | |||
| 79 | it("keeps localhost navigation local for a local environment", async () => { | ||
| 80 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); | ||
| 81 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | ||
| 82 | expect( | ||
| 83 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | ||
| 84 | kind: "url", | ||
| 85 | url: "localhost:3000/app", | ||
| 86 | }), | ||
| 87 | ).toEqual({ | ||
| 88 | requestedUrl: "localhost:3000/app", | ||
| 89 | resolvedUrl: "localhost:3000/app", | ||
| 90 | resolutionKind: "direct", | ||
| 91 | environmentId: "environment-1", | ||
| 92 | }); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("keeps localhost navigation local for the full IPv4 loopback range", async () => { | ||
| 96 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.2:3773" }); | ||
| 97 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | ||
| 98 | expect( | ||
| 99 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | ||
| 100 | kind: "url", | ||
| 101 | url: "http://localhost:3000/app", | ||
| 102 | }), | ||
| 103 | ).toEqual({ | ||
| 104 | requestedUrl: "http://localhost:3000/app", | ||
| 105 | resolvedUrl: "http://localhost:3000/app", | ||
| 106 | resolutionKind: "direct", | ||
| 107 | environmentId: "environment-1", | ||
| 108 | }); | ||
| 109 | }); | ||
| 110 | |||
| 28 | it("refuses public relay hosts until the authenticated gateway exists", async () => { | 111 | it("refuses public relay hosts until the authenticated gateway exists", async () => { |
| 29 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); | 112 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); |
| 30 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | 113 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); |
| ... | @@ -34,6 +117,12 @@ describe("browser target resolver", () => { | ... | @@ -34,6 +117,12 @@ describe("browser target resolver", () => { |
| 34 | port: 5173, | 117 | port: 5173, |
| 35 | }), | 118 | }), |
| 36 | ).toThrow(/authenticated preview gateway/); | 119 | ).toThrow(/authenticated preview gateway/); |
| 120 | expect(() => | ||
| 121 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | ||
| 122 | kind: "url", | ||
| 123 | url: "http://localhost:5173", | ||
| 124 | }), | ||
| 125 | ).toThrow(/authenticated preview gateway/); | ||
| 37 | }); | 126 | }); |
| 38 | 127 | ||
| 39 | it("normalizes schemeless localhost server-picker values", async () => { | 128 | it("normalizes schemeless localhost server-picker values", async () => { |
| ... | @@ -63,7 +152,9 @@ describe("browser target resolver", () => { | ... | @@ -63,7 +152,9 @@ describe("browser target resolver", () => { |
| 63 | }); | 152 | }); |
| 64 | 153 | ||
| 65 | it("supports private IPv6 environment hosts", async () => { | 154 | it("supports private IPv6 environment hosts", async () => { |
| 66 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); | 155 | readPreparedConnection.mockReturnValue({ |
| 156 | httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", | ||
| 157 | }); | ||
| 67 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | 158 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); |
| 68 | expect( | 159 | expect( |
| 69 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | 160 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { |
| ... | @@ -71,7 +162,18 @@ describe("browser target resolver", () => { | ... | @@ -71,7 +162,18 @@ describe("browser target resolver", () => { |
| 71 | port: 5173, | 162 | port: 5173, |
| 72 | path: "/app?mode=test", | 163 | path: "/app?mode=test", |
| 73 | }).resolvedUrl, | 164 | }).resolvedUrl, |
| 74 | ).toBe("http://[::1]:5173/app?mode=test"); | 165 | ).toBe("http://[fd7a:115c:a1e0::53]:5173/app?mode=test"); |
| 166 | }); | ||
| 167 | |||
| 168 | it("supports a local IPv6 environment host", async () => { | ||
| 169 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); | ||
| 170 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | ||
| 171 | expect( | ||
| 172 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | ||
| 173 | kind: "environment-port", | ||
| 174 | port: 5173, | ||
| 175 | }).resolvedUrl, | ||
| 176 | ).toBe("http://[::1]:5173/"); | ||
| 75 | }); | 177 | }); |
| 76 | 178 | ||
| 77 | it("leaves malformed input for the normal navigation error path", async () => { | 179 | it("leaves malformed input for the normal navigation error path", async () => { |
apps/web/src/browser/browserTargetResolver.ts+95-46| ... | @@ -7,43 +7,60 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; | ... | @@ -7,43 +7,60 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 7 | 7 | ||
| 8 | import { readPreparedConnection } from "~/state/session"; | 8 | import { readPreparedConnection } from "~/state/session"; |
| 9 | 9 | ||
| 10 | const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); | ||
| 11 | |||
| 12 | const parseIpv4Address = (host: string): readonly number[] | null => { | ||
| 13 | const parts = normalizeHostname(host).split(".").map(Number); | ||
| 14 | return parts.length === 4 && | ||
| 15 | parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) | ||
| 16 | ? parts | ||
| 17 | : null; | ||
| 18 | }; | ||
| 19 | |||
| 20 | const isLocalLoopbackHost = (host: string): boolean => { | ||
| 21 | const normalized = normalizeHostname(host); | ||
| 22 | if (normalized === "localhost" || normalized === "::1") return true; | ||
| 23 | return parseIpv4Address(normalized)?.[0] === 127; | ||
| 24 | }; | ||
| 25 | |||
| 10 | const isPrivateNetworkHost = (host: string): boolean => { | 26 | const isPrivateNetworkHost = (host: string): boolean => { |
| 11 | const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); | 27 | const normalized = normalizeHostname(host); |
| 12 | if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { | 28 | if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) { |
| 13 | return true; | 29 | return true; |
| 14 | } | 30 | } |
| 15 | if (normalized.endsWith(".ts.net")) return true; | 31 | if (normalized.endsWith(".ts.net")) return true; |
| 16 | const parts = normalized.split(".").map(Number); | 32 | const parts = parseIpv4Address(normalized); |
| 17 | if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; | 33 | if (parts) { |
| 34 | return ( | ||
| 35 | parts[0] === 10 || | ||
| 36 | (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || | ||
| 37 | (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || | ||
| 38 | (parts[0] === 192 && parts[1] === 168) || | ||
| 39 | (parts[0] === 169 && parts[1] === 254) | ||
| 40 | ); | ||
| 41 | } | ||
| 42 | const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; | ||
| 43 | if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; | ||
| 44 | const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); | ||
| 18 | return ( | 45 | return ( |
| 19 | parts[0] === 10 || | 46 | Number.isInteger(firstIpv6Hextet) && |
| 20 | (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || | 47 | ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) |
| 21 | (parts[0] === 192 && parts[1] === 168) || | ||
| 22 | parts[0] === 127 || | ||
| 23 | (parts[0] === 169 && parts[1] === 254) | ||
| 24 | ); | 48 | ); |
| 25 | }; | 49 | }; |
| 26 | 50 | ||
| 27 | const isLocalLoopbackHost = (host: string): boolean => { | 51 | const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { |
| 28 | const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); | 52 | const connection = readPreparedConnection(environmentId); |
| 29 | return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; | 53 | if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); |
| 54 | return new URL(connection.httpBaseUrl); | ||
| 30 | }; | 55 | }; |
| 31 | 56 | ||
| 32 | export function resolveBrowserNavigationTarget( | 57 | const resolveEnvironmentPortTarget = ( |
| 33 | environmentId: EnvironmentId, | 58 | environmentId: EnvironmentId, |
| 34 | target: BrowserNavigationTarget, | 59 | target: Extract<BrowserNavigationTarget, { readonly kind: "environment-port" }>, |
| 35 | ): PreviewUrlResolution { | 60 | environmentUrl: URL, |
| 36 | if (target.kind === "url") { | 61 | requestedUrl?: string, |
| 37 | return { | 62 | sourceUrl?: URL, |
| 38 | requestedUrl: target.url, | 63 | ): PreviewUrlResolution => { |
| 39 | resolvedUrl: target.url, | ||
| 40 | resolutionKind: "direct", | ||
| 41 | environmentId, | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | const connection = readPreparedConnection(environmentId); | ||
| 45 | if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); | ||
| 46 | const environmentUrl = new URL(connection.httpBaseUrl); | ||
| 47 | if (!isPrivateNetworkHost(environmentUrl.hostname)) { | 64 | if (!isPrivateNetworkHost(environmentUrl.hostname)) { |
| 48 | throw new Error( | 65 | throw new Error( |
| 49 | "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", | 66 | "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", |
| ... | @@ -51,40 +68,72 @@ export function resolveBrowserNavigationTarget( | ... | @@ -51,40 +68,72 @@ export function resolveBrowserNavigationTarget( |
| 51 | } | 68 | } |
| 52 | const protocol = target.protocol ?? "http"; | 69 | const protocol = target.protocol ?? "http"; |
| 53 | const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; | 70 | const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; |
| 54 | const requestedUrl = `${protocol}://localhost:${target.port}${path}`; | ||
| 55 | const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); | 71 | const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); |
| 56 | const resolvedHost = normalizedEnvironmentHost.includes(":") | 72 | const resolvedHost = normalizedEnvironmentHost.includes(":") |
| 57 | ? `[${normalizedEnvironmentHost}]` | 73 | ? `[${normalizedEnvironmentHost}]` |
| 58 | : normalizedEnvironmentHost; | 74 | : normalizedEnvironmentHost; |
| 59 | const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); | 75 | const resolved = sourceUrl |
| 76 | ? new URL(sourceUrl) | ||
| 77 | : new URL(path, `${protocol}://${resolvedHost}:${target.port}`); | ||
| 78 | if (sourceUrl) { | ||
| 79 | resolved.hostname = resolvedHost; | ||
| 80 | resolved.port = String(target.port); | ||
| 81 | } | ||
| 60 | return { | 82 | return { |
| 61 | requestedUrl, | 83 | requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`, |
| 62 | resolvedUrl: resolved.toString(), | 84 | resolvedUrl: resolved.toString(), |
| 63 | resolutionKind: | 85 | resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost) |
| 64 | normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1" | 86 | ? "direct" |
| 65 | ? "direct" | 87 | : "direct-private-network", |
| 66 | : "direct-private-network", | ||
| 67 | environmentId, | 88 | environmentId, |
| 68 | }; | 89 | }; |
| 90 | }; | ||
| 91 | |||
| 92 | export function resolveBrowserNavigationTarget( | ||
| 93 | environmentId: EnvironmentId, | ||
| 94 | target: BrowserNavigationTarget, | ||
| 95 | ): PreviewUrlResolution { | ||
| 96 | if (target.kind === "url") { | ||
| 97 | let parsed: URL | null = null; | ||
| 98 | try { | ||
| 99 | parsed = new URL(normalizePreviewUrl(target.url)); | ||
| 100 | } catch { | ||
| 101 | // Preserve the existing direct-navigation behavior so the preview host | ||
| 102 | // reports malformed URL errors through its normal navigation path. | ||
| 103 | } | ||
| 104 | if (parsed && isLoopbackHost(parsed.hostname)) { | ||
| 105 | const environmentUrl = readEnvironmentUrl(environmentId); | ||
| 106 | if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { | ||
| 107 | return resolveEnvironmentPortTarget( | ||
| 108 | environmentId, | ||
| 109 | { | ||
| 110 | kind: "environment-port", | ||
| 111 | port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), | ||
| 112 | protocol: parsed.protocol === "https:" ? "https" : "http", | ||
| 113 | path: `${parsed.pathname}${parsed.search}${parsed.hash}`, | ||
| 114 | }, | ||
| 115 | environmentUrl, | ||
| 116 | target.url, | ||
| 117 | parsed, | ||
| 118 | ); | ||
| 119 | } | ||
| 120 | } | ||
| 121 | return { | ||
| 122 | requestedUrl: target.url, | ||
| 123 | resolvedUrl: target.url, | ||
| 124 | resolutionKind: "direct", | ||
| 125 | environmentId, | ||
| 126 | }; | ||
| 127 | } | ||
| 128 | return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId)); | ||
| 69 | } | 129 | } |
| 70 | 130 | ||
| 71 | export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { | 131 | export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { |
| 72 | try { | 132 | try { |
| 73 | const normalizedUrl = normalizePreviewUrl(rawUrl); | 133 | const normalizedUrl = normalizePreviewUrl(rawUrl); |
| 74 | const parsed = new URL(normalizedUrl); | ||
| 75 | if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; | ||
| 76 | const connection = readPreparedConnection(environmentId); | ||
| 77 | if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); | ||
| 78 | const environmentUrl = new URL(connection.httpBaseUrl); | ||
| 79 | if (parsed.hostname !== "0.0.0.0" && isLocalLoopbackHost(environmentUrl.hostname)) { | ||
| 80 | return normalizedUrl; | ||
| 81 | } | ||
| 82 | const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); | ||
| 83 | return resolveBrowserNavigationTarget(environmentId, { | 134 | return resolveBrowserNavigationTarget(environmentId, { |
| 84 | kind: "environment-port", | 135 | kind: "url", |
| 85 | port, | 136 | url: normalizedUrl, |
| 86 | protocol: parsed.protocol === "https:" ? "https" : "http", | ||
| 87 | path: `${parsed.pathname}${parsed.search}${parsed.hash}`, | ||
| 88 | }).resolvedUrl; | 137 | }).resolvedUrl; |
| 89 | } catch { | 138 | } catch { |
| 90 | return rawUrl; | 139 | return rawUrl; |
apps/web/src/components/preview/PreviewAutomationHosts.tsx+9-7| ... | @@ -343,6 +343,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -343,6 +343,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 343 | return await currentStatus(threadRef, tabId); | 343 | return await currentStatus(threadRef, tabId); |
| 344 | case "open": { | 344 | case "open": { |
| 345 | const input = request.input as PreviewAutomationOpenInput; | 345 | const input = request.input as PreviewAutomationOpenInput; |
| 346 | const resolvedInputUrl = input.url | ||
| 347 | ? resolveBrowserNavigationTarget(environmentId, { | ||
| 348 | kind: "url", | ||
| 349 | url: input.url, | ||
| 350 | }).resolvedUrl | ||
| 351 | : undefined; | ||
| 346 | let activeTabId = resolvePreviewAutomationOpenTab( | 352 | let activeTabId = resolvePreviewAutomationOpenTab( |
| 347 | state, | 353 | state, |
| 348 | request.tabId, | 354 | request.tabId, |
| ... | @@ -358,7 +364,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -358,7 +364,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 358 | environmentId, | 364 | environmentId, |
| 359 | input: { | 365 | input: { |
| 360 | threadId: request.threadId, | 366 | threadId: request.threadId, |
| 361 | ...(input.url ? { url: input.url } : {}), | 367 | ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), |
| 362 | }, | 368 | }, |
| 363 | }); | 369 | }); |
| 364 | if (result._tag === "Failure") { | 370 | if (result._tag === "Failure") { |
| ... | @@ -381,12 +387,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -381,12 +387,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 381 | request.timeoutMs, | 387 | request.timeoutMs, |
| 382 | ); | 388 | ); |
| 383 | } | 389 | } |
| 384 | if (reusedExistingTab && input.url && previewBridge) { | 390 | if (reusedExistingTab && resolvedInputUrl && previewBridge) { |
| 385 | const resolution = resolveBrowserNavigationTarget(environmentId, { | 391 | await previewBridge.navigate(activeTabId, resolvedInputUrl); |
| 386 | kind: "url", | ||
| 387 | url: input.url, | ||
| 388 | }); | ||
| 389 | await previewBridge.navigate(activeTabId, resolution.resolvedUrl); | ||
| 390 | await waitForNavigationReadiness( | 392 | await waitForNavigationReadiness( |
| 391 | threadRef, | 393 | threadRef, |
| 392 | request.requestId, | 394 | request.requestId, |