From 56e075064f0bfb2e37d0983fc3d4a7e2fb1c1778 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:42:58 -0700 Subject: [PATCH] fix(web): structure preview asset URL failures Co-authored-by: codex --- .../web/src/browser/openFileInPreview.test.ts | 64 +++++++++++++- apps/web/src/browser/openFileInPreview.ts | 87 +++++++++++++++---- apps/web/src/components/ChatMarkdown.tsx | 27 +++--- 3 files changed, 147 insertions(+), 31 deletions(-) diff --git a/apps/web/src/browser/openFileInPreview.test.ts b/apps/web/src/browser/openFileInPreview.test.ts index ec86cff252a..5cf24fa6ab9 100644 --- a/apps/web/src/browser/openFileInPreview.test.ts +++ b/apps/web/src/browser/openFileInPreview.test.ts @@ -1,15 +1,22 @@ import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { beforeEach, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; -import { type OpenPreviewMutation, openUrlInPreview } from "./openFileInPreview"; +import { + BrowserPreviewUnavailableError, + isBrowserPreviewAssetUrlInvalidError, + type OpenPreviewMutation, + openFileInPreview, + openUrlInPreview, +} from "./openFileInPreview"; const threadRef = { - environmentId: "local" as ScopedThreadRef["environmentId"], + environmentId: "environment-1" as ScopedThreadRef["environmentId"], threadId: "thread-1" as ScopedThreadRef["threadId"], }; @@ -27,6 +34,57 @@ beforeEach(() => { useRightPanelStore.setState({ byThreadKey: {} }); }); +afterEach(() => vi.unstubAllGlobals()); + +describe("openFileInPreview", () => { + it("uses the fixed unavailable-runtime message", () => { + expect(new BrowserPreviewUnavailableError().message).toBe( + "The integrated browser is unavailable in this runtime.", + ); + }); + + it("reports invalid asset URLs with safe context and the exact parser cause", async () => { + vi.stubGlobal("window", { desktopBridge: { preview: {} } }); + const parserCause = new TypeError("invalid URL"); + const InvalidUrl = vi.fn(function InvalidUrl() { + throw parserCause; + }); + vi.stubGlobal("URL", InvalidUrl); + const openPreview = vi.fn(); + const httpBaseUrl = "not a URL"; + const relativeUrl = "/api/assets/signed-secret-token/docs/report.pdf"; + const expiresAt = Date.now(); + + const result = await openFileInPreview({ + threadRef, + filePath: "docs/report.pdf", + httpBaseUrl, + createAssetUrl: async () => AsyncResult.success({ relativeUrl, expiresAt }), + openPreview, + }); + const error = result._tag === "Failure" ? Cause.squash(result.cause) : undefined; + + expect(isBrowserPreviewAssetUrlInvalidError(error)).toBe(true); + if (!isBrowserPreviewAssetUrlInvalidError(error)) { + throw new Error("Expected BrowserPreviewAssetUrlInvalidError"); + } + expect(error).toMatchObject({ + environmentId: "environment-1", + threadId: "thread-1", + filePath: "docs/report.pdf", + httpBaseUrlLength: httpBaseUrl.length, + relativeUrlLength: relativeUrl.length, + expiresAt, + }); + expect(error.cause).toBe(parserCause); + expect(error.message).toBe("The environment returned an invalid asset URL."); + expect(error).not.toHaveProperty("httpBaseUrl"); + expect(error).not.toHaveProperty("relativeUrl"); + expect(JSON.stringify(error)).not.toContain("signed-secret-token"); + expect(openPreview).not.toHaveBeenCalled(); + }); +}); + it("does not apply an older preview response after a newer request", async () => { const firstController = new AbortController(); const firstSnapshot = snapshot("tab-1", "https://assets.test/first.png"); diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index d07be0f5281..e2e205367cb 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -16,10 +16,9 @@ import { isWorkspacePreviewEntryPath, } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; -import { resolveAssetUrl } from "~/assets/assetUrls"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime, @@ -31,11 +30,54 @@ export const isBrowserPreviewFile = isWorkspaceBrowserPreviewPath; export const isImagePreviewFile = isWorkspaceImagePreviewPath; export const isWorkspacePreviewFile = isWorkspacePreviewEntryPath; -export class BrowserPreviewUnavailableError extends Data.TaggedError( +export class BrowserPreviewUnavailableError extends Schema.TaggedErrorClass()( "BrowserPreviewUnavailableError", -)<{ - readonly message: string; -}> {} + {}, +) { + override get message(): string { + return "The integrated browser is unavailable in this runtime."; + } +} + +export class BrowserPreviewThreadContextUnavailableError extends Schema.TaggedErrorClass()( + "BrowserPreviewThreadContextUnavailableError", + {}, +) { + override get message(): string { + return "Thread context is unavailable."; + } +} + +export class BrowserPreviewEnvironmentDisconnectedError extends Schema.TaggedErrorClass()( + "BrowserPreviewEnvironmentDisconnectedError", + { + environmentId: Schema.String, + threadId: Schema.String, + }, +) { + override get message(): string { + return "Environment is not connected."; + } +} + +export class BrowserPreviewAssetUrlInvalidError extends Schema.TaggedErrorClass()( + "BrowserPreviewAssetUrlInvalidError", + { + environmentId: Schema.String, + threadId: Schema.String, + filePath: Schema.String, + httpBaseUrlLength: Schema.Int, + relativeUrlLength: Schema.Int, + expiresAt: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "The environment returned an invalid asset URL."; + } +} + +export const isBrowserPreviewAssetUrlInvalidError = Schema.is(BrowserPreviewAssetUrlInvalidError); export type OpenPreviewMutation = (input: { readonly environmentId: EnvironmentId; @@ -70,15 +112,14 @@ export async function openFileInPreview(input: { }) => Promise>; readonly openPreview: OpenPreviewMutation; readonly signal?: AbortSignal; -}): Promise> { +}): Promise< + AtomCommandResult< + void, + AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserPreviewAssetUrlInvalidError + > +> { if (!isPreviewSupportedInRuntime()) { - return AsyncResult.failure( - Cause.fail( - new BrowserPreviewUnavailableError({ - message: "The integrated browser is unavailable in this runtime.", - }), - ), - ); + return AsyncResult.failure(Cause.fail(new BrowserPreviewUnavailableError())); } const assetResult = await input.createAssetUrl({ environmentId: input.threadRef.environmentId, @@ -96,10 +137,22 @@ export async function openFileInPreview(input: { if (assetResult._tag === "Failure") { return AsyncResult.failure(assetResult.cause); } - const assetUrl = resolveAssetUrl(input.httpBaseUrl, assetResult.value.relativeUrl); - if (assetUrl === null) { + let assetUrl: string; + try { + assetUrl = new URL(assetResult.value.relativeUrl, input.httpBaseUrl).toString(); + } catch (cause) { return AsyncResult.failure( - Cause.die(new Error("The environment returned an invalid asset URL.")), + Cause.fail( + new BrowserPreviewAssetUrlInvalidError({ + environmentId: input.threadRef.environmentId, + threadId: input.threadRef.threadId, + filePath: input.filePath, + httpBaseUrlLength: input.httpBaseUrl.length, + relativeUrlLength: assetResult.value.relativeUrl.length, + expiresAt: assetResult.value.expiresAt, + cause, + }), + ), ); } return openUrlInPreview({ diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 014fe17aad3..f5ac502254c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -79,7 +79,8 @@ import { isWorkspacePreviewFile, openFileInPreview, openUrlInPreview, - BrowserPreviewUnavailableError, + BrowserPreviewEnvironmentDisconnectedError, + BrowserPreviewThreadContextUnavailableError, } from "../browser/openFileInPreview"; class CodeHighlightErrorBoundary extends React.Component< @@ -1292,12 +1293,8 @@ function ChatMarkdown({ (url: string) => { if (!threadRef) { return Promise.resolve( - AsyncResult.failure( - Cause.fail( - new BrowserPreviewUnavailableError({ - message: "Thread context is unavailable.", - }), - ), + AsyncResult.failure( + Cause.fail(new BrowserPreviewThreadContextUnavailableError()), ), ); } @@ -1307,12 +1304,20 @@ function ChatMarkdown({ ); const openMarkdownFileInPreview = useCallback( (path: string) => { - if (!threadRef || preparedConnection._tag === "None") { + if (!threadRef) { + return Promise.resolve( + AsyncResult.failure( + Cause.fail(new BrowserPreviewThreadContextUnavailableError()), + ), + ); + } + if (preparedConnection._tag === "None") { return Promise.resolve( - AsyncResult.failure( + AsyncResult.failure( Cause.fail( - new BrowserPreviewUnavailableError({ - message: "Environment is not connected.", + new BrowserPreviewEnvironmentDisconnectedError({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, }), ), ),