Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 61 additions & 3 deletions apps/web/src/browser/openFileInPreview.test.ts
Original file line number Diff line number Diff line change
@@ -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"],
};

Expand All @@ -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");
Expand Down
87 changes: 70 additions & 17 deletions apps/web/src/browser/openFileInPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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>()(
"BrowserPreviewUnavailableError",
)<{
readonly message: string;
}> {}
{},
) {
override get message(): string {
return "The integrated browser is unavailable in this runtime.";
}
}

export class BrowserPreviewThreadContextUnavailableError extends Schema.TaggedErrorClass<BrowserPreviewThreadContextUnavailableError>()(
"BrowserPreviewThreadContextUnavailableError",
{},
) {
override get message(): string {
return "Thread context is unavailable.";
}
}

export class BrowserPreviewEnvironmentDisconnectedError extends Schema.TaggedErrorClass<BrowserPreviewEnvironmentDisconnectedError>()(
"BrowserPreviewEnvironmentDisconnectedError",
{
environmentId: Schema.String,
threadId: Schema.String,
},
) {
override get message(): string {
return "Environment is not connected.";
}
}

export class BrowserPreviewAssetUrlInvalidError extends Schema.TaggedErrorClass<BrowserPreviewAssetUrlInvalidError>()(
"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<E = unknown> = (input: {
readonly environmentId: EnvironmentId;
Expand Down Expand Up @@ -70,15 +112,14 @@ export async function openFileInPreview<AssetError, PreviewError>(input: {
}) => Promise<AtomCommandResult<AssetCreateUrlResult, AssetError>>;
readonly openPreview: OpenPreviewMutation<PreviewError>;
readonly signal?: AbortSignal;
}): Promise<AtomCommandResult<void, AssetError | PreviewError | BrowserPreviewUnavailableError>> {
}): 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,
Expand All @@ -96,10 +137,22 @@ export async function openFileInPreview<AssetError, PreviewError>(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({
Expand Down
27 changes: 16 additions & 11 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ import {
isWorkspacePreviewFile,
openFileInPreview,
openUrlInPreview,
BrowserPreviewUnavailableError,
BrowserPreviewEnvironmentDisconnectedError,
BrowserPreviewThreadContextUnavailableError,
} from "../browser/openFileInPreview";

class CodeHighlightErrorBoundary extends React.Component<
Expand Down Expand Up @@ -1292,12 +1293,8 @@ function ChatMarkdown({
(url: string) => {
if (!threadRef) {
return Promise.resolve(
AsyncResult.failure<void, BrowserPreviewUnavailableError>(
Cause.fail(
new BrowserPreviewUnavailableError({
message: "Thread context is unavailable.",
}),
),
AsyncResult.failure<void, BrowserPreviewThreadContextUnavailableError>(
Cause.fail(new BrowserPreviewThreadContextUnavailableError()),
),
);
}
Expand All @@ -1307,12 +1304,20 @@ function ChatMarkdown({
);
const openMarkdownFileInPreview = useCallback(
(path: string) => {
if (!threadRef || preparedConnection._tag === "None") {
if (!threadRef) {
return Promise.resolve(
AsyncResult.failure<void, BrowserPreviewThreadContextUnavailableError>(
Cause.fail(new BrowserPreviewThreadContextUnavailableError()),
),
);
}
if (preparedConnection._tag === "None") {
return Promise.resolve(
AsyncResult.failure<void, BrowserPreviewUnavailableError>(
AsyncResult.failure<void, BrowserPreviewEnvironmentDisconnectedError>(
Cause.fail(
new BrowserPreviewUnavailableError({
message: "Environment is not connected.",
new BrowserPreviewEnvironmentDisconnectedError({
environmentId: threadRef.environmentId,
threadId: threadRef.threadId,
}),
),
),
Expand Down
Loading