Skip to content
134 changes: 134 additions & 0 deletions apps/web/src/browser/openFileInPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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 { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore";
import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore";

import {
BrowserPreviewUnavailableError,
isBrowserPreviewAssetUrlInvalidError,
type OpenPreviewMutation,
openFileInPreview,
openUrlInPreview,
} from "./openFileInPreview";

const threadRef = {
environmentId: "environment-1" as ScopedThreadRef["environmentId"],
threadId: "thread-1" as ScopedThreadRef["threadId"],
};

const snapshot = (tabId: string, url: string): PreviewSessionSnapshot => ({
threadId: threadRef.threadId,
tabId,
navStatus: { _tag: "Success", url, title: "" },
canGoBack: false,
canGoForward: false,
updatedAt: "2026-06-21T00:00:00.000Z",
});

beforeEach(() => {
resetPreviewStateForTests();
useRightPanelStore.setState({ byThreadKey: {} });
});

afterEach(() => vi.unstubAllGlobals());

describe("openFileInPreview", () => {
it("reports an unavailable runtime with thread context", async () => {
vi.stubGlobal("window", {});

const result = await openFileInPreview({
threadRef,
filePath: "docs/report.pdf",
httpBaseUrl: "https://environment.test",
createAssetUrl: vi.fn(),
openPreview: vi.fn(),
});
const error = result._tag === "Failure" ? Cause.squash(result.cause) : undefined;

expect(error).toEqual(
new BrowserPreviewUnavailableError({
environmentId: "environment-1",
threadId: "thread-1",
}),
);
expect(error).toMatchObject({
message: "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 another caller starts a newer request", async () => {
const firstSnapshot = snapshot("tab-1", "https://assets.test/first.png");
const secondSnapshot = snapshot("tab-2", "https://assets.test/second.png");
let resolveFirst!: (result: AtomCommandResult<PreviewSessionSnapshot, never>) => void;
const openPreview: OpenPreviewMutation<never> = ({ input }) =>
input.url === "https://assets.test/first.png"
? new Promise<AtomCommandResult<PreviewSessionSnapshot, never>>((resolve) => {
resolveFirst = resolve;
})
: Promise.resolve(AsyncResult.success(secondSnapshot));

const firstRequest = openUrlInPreview({
threadRef,
url: "https://assets.test/first.png",
openPreview,
});

await openUrlInPreview({
threadRef,
url: "https://assets.test/second.png",
openPreview,
});
resolveFirst(AsyncResult.success(firstSnapshot));
await firstRequest;

expect(readThreadPreviewState(threadRef).snapshot).toEqual(secondSnapshot);
expect(
selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces,
).toEqual([{ id: "browser:tab-2", kind: "preview", resourceId: "tab-2" }]);
});
215 changes: 171 additions & 44 deletions apps/web/src/browser/openFileInPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,50 +6,147 @@ import type {
PreviewSessionSnapshot,
ScopedThreadRef,
} from "@t3tools/contracts";
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import {
type AtomCommandResult,
mapAtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
import {
isWorkspaceBrowserPreviewPath,
isWorkspaceImagePreviewPath,
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,
rememberPreviewUrl,
} from "~/previewStateStore";
import { useRightPanelStore } from "~/rightPanelStore";

export const isBrowserPreviewFile = (path: string): boolean =>
/\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
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;
}> {}
{
environmentId: Schema.String,
threadId: Schema.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;
readonly input: PreviewOpenInput;
}) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>;

export async function openUrlInPreview<E>(input: {
readonly threadRef: ScopedThreadRef;
readonly url: string;
readonly openPreview: OpenPreviewMutation<E>;
}): Promise<AtomCommandResult<void, E>> {
interface PreviewRequest {
readonly isCurrent: () => boolean;
readonly complete: () => void;
}

const activePreviewRequestByThread = new Map<string, symbol>();

const beginPreviewRequest = (
threadRef: ScopedThreadRef,
signal: AbortSignal | undefined,
): PreviewRequest => {
const threadKey = scopedThreadKey(threadRef);
const requestId = Symbol();
activePreviewRequestByThread.set(threadKey, requestId);
return {
isCurrent: () =>
activePreviewRequestByThread.get(threadKey) === requestId && signal?.aborted !== true,
complete: () => {
if (activePreviewRequestByThread.get(threadKey) === requestId) {
activePreviewRequestByThread.delete(threadKey);
}
},
};
};

const openUrlForPreviewRequest = async <E>(
input: {
readonly threadRef: ScopedThreadRef;
readonly url: string;
readonly openPreview: OpenPreviewMutation<E>;
},
request: PreviewRequest,
): Promise<AtomCommandResult<void, E>> => {
const result = await input.openPreview({
environmentId: input.threadRef.environmentId,
input: { threadId: input.threadRef.threadId, url: input.url },
});
if (!request.isCurrent()) {
return AsyncResult.success(undefined);
}
return mapAtomCommandResult(result, (snapshot) => {
applyPreviewServerSnapshot(input.threadRef, snapshot);
rememberPreviewUrl(input.threadRef, input.url);
useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);
});
};

export async function openUrlInPreview<E>(input: {
readonly threadRef: ScopedThreadRef;
readonly url: string;
readonly openPreview: OpenPreviewMutation<E>;
readonly signal?: AbortSignal;
}): Promise<AtomCommandResult<void, E>> {
const request = beginPreviewRequest(input.threadRef, input.signal);
try {
return await openUrlForPreviewRequest(input, request);
} finally {
request.complete();
}
}

export async function openFileInPreview<AssetError, PreviewError>(input: {
Expand All @@ -61,38 +158,68 @@ export async function openFileInPreview<AssetError, PreviewError>(input: {
readonly input: { readonly resource: AssetResource };
}) => Promise<AtomCommandResult<AssetCreateUrlResult, AssetError>>;
readonly openPreview: OpenPreviewMutation<PreviewError>;
}): Promise<AtomCommandResult<void, AssetError | PreviewError | BrowserPreviewUnavailableError>> {
if (!isPreviewSupportedInRuntime()) {
return AsyncResult.failure(
Cause.fail(
new BrowserPreviewUnavailableError({
message: "The integrated browser is unavailable in this runtime.",
}),
),
);
}
const assetResult = await input.createAssetUrl({
environmentId: input.threadRef.environmentId,
input: {
resource: {
_tag: "workspace-file",
threadId: input.threadRef.threadId,
path: input.filePath,
readonly signal?: AbortSignal;
}): Promise<
AtomCommandResult<
void,
AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserPreviewAssetUrlInvalidError
>
> {
const request = beginPreviewRequest(input.threadRef, input.signal);
try {
if (!isPreviewSupportedInRuntime()) {
return AsyncResult.failure(
Cause.fail(
new BrowserPreviewUnavailableError({
environmentId: input.threadRef.environmentId,
threadId: input.threadRef.threadId,
}),
),
);
}
const assetResult = await input.createAssetUrl({
environmentId: input.threadRef.environmentId,
input: {
resource: {
_tag: "workspace-file",
threadId: input.threadRef.threadId,
path: input.filePath,
},
},
},
});
if (assetResult._tag === "Failure") {
return AsyncResult.failure(assetResult.cause);
}
const assetUrl = resolveAssetUrl(input.httpBaseUrl, assetResult.value.relativeUrl);
if (assetUrl === null) {
return AsyncResult.failure(
Cause.die(new Error("The environment returned an invalid asset URL.")),
});
if (!request.isCurrent()) {
return AsyncResult.success(undefined);
}
if (assetResult._tag === "Failure") {
return AsyncResult.failure(assetResult.cause);
}
let assetUrl: string;
try {
assetUrl = new URL(assetResult.value.relativeUrl, input.httpBaseUrl).toString();
} catch (cause) {
return AsyncResult.failure(
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 await openUrlForPreviewRequest(
{
threadRef: input.threadRef,
url: assetUrl,
openPreview: input.openPreview,
},
request,
);
} finally {
request.complete();
}
return openUrlInPreview({
threadRef: input.threadRef,
url: assetUrl,
openPreview: input.openPreview,
});
}
Loading
Loading