Skip to content
Open
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
186 changes: 186 additions & 0 deletions apps/web/src/browser/openFileInPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import type { AssetCreateUrlResult, ScopedThreadRef } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import { describe, expect, it, vi } from "vite-plus/test";

import {
beginExternalFileOpen,
isBrowserPreviewFile,
openFileInExternalBrowser,
} from "./openFileInPreview";

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

const assetResult = (relativeUrl: string): AssetCreateUrlResult => ({
relativeUrl,
expiresAt: 1_750_000_000_000,
});

describe("isBrowserPreviewFile", () => {
it.each(["report.html", "doc.HTM", "paper.pdf", "nested/page.html?x=1#top"])(
"accepts %s",
(path) => expect(isBrowserPreviewFile(path)).toBe(true),
);
it.each(["notes.md", "index.html.bak", "script.js"])("rejects %s", (path) => {
expect(isBrowserPreviewFile(path)).toBe(false);
});
});

describe("beginExternalFileOpen", () => {
it("uses the desktop shell without opening a web tab", async () => {
const openExternal = vi.fn(async () => undefined);
const openWindow = vi.fn();
const session = beginExternalFileOpen({ isDesktop: true, openExternal, openWindow });

await session.open("http://environment.test/report.pdf");

expect(openExternal).toHaveBeenCalledWith("http://environment.test/report.pdf");
expect(openWindow).not.toHaveBeenCalled();
});

it("reserves and isolates a web tab before navigation", async () => {
const replace = vi.fn();
const close = vi.fn();
const tab = { opener: {} as Window | null, location: { replace }, close };
const session = beginExternalFileOpen({
isDesktop: false,
openExternal: vi.fn(),
openWindow: () => tab as unknown as Pick<Window, "close" | "location" | "opener">,
});

expect(tab.opener).toBeNull();
await session.open("http://environment.test/report.html");
expect(replace).toHaveBeenCalledWith("http://environment.test/report.html");
session.cancel();
expect(close).toHaveBeenCalledOnce();
});

it("reports a blocked web tab", () => {
expect(() =>
beginExternalFileOpen({
isDesktop: false,
openExternal: vi.fn(),
openWindow: () => null,
}),
).toThrow("The browser blocked the new tab.");
});
});

describe("openFileInExternalBrowser", () => {
it.each([
["/repo/artifacts/report.html", "/repo", "workspace-file"],
["/tmp/report.html", "/repo", "media-file"],
["/repo-other/report.pdf", "/repo", "media-file"],
["C:/repo/report.pdf", "C:/repo", "workspace-file"],
["C:/temp/report.pdf", "C:/repo", "media-file"],
] as const)(
"opens %s using %s and the matching resource scope",
async (filePath, workspaceRoot, resourceTag) => {
const createAssetUrl = vi.fn(async () =>
AsyncResult.success(assetResult("/api/assets/token/report")),
);
const result = await openFileInExternalBrowser({
threadRef,
filePath,
workspaceRoot,
httpBaseUrl: "http://environment.test",
createAssetUrl,
beginOpen: () => ({ open: vi.fn(async () => undefined), cancel: vi.fn() }),
});
expect(result._tag).toBe("Success");
expect(createAssetUrl).toHaveBeenCalledWith({
environmentId: threadRef.environmentId,
input: { resource: { _tag: resourceTag, threadId: threadRef.threadId, path: filePath } },
});
},
);

it("reserves the browser target before requesting the signed URL", async () => {
const calls: string[] = [];
const open = vi.fn(async (url: string): Promise<void> => {
calls.push(`open:${url}`);
});
const cancel = vi.fn();
const createAssetUrl = vi.fn(async () => {
calls.push("asset");
return AsyncResult.success(assetResult("/api/assets/token/report.html"));
});

const result = await openFileInExternalBrowser({
threadRef,
workspaceRoot: "/repo",
filePath: "/repo/artifacts/report.html",
httpBaseUrl: "http://environment.test:1234",
createAssetUrl,
beginOpen: () => {
calls.push("begin");
return { open, cancel };
},
});

expect(result._tag).toBe("Success");
expect(calls).toEqual([
"begin",
"asset",
"open:http://environment.test:1234/api/assets/token/report.html",
]);
expect(cancel).not.toHaveBeenCalled();
});

it("closes the reserved target when signed URL creation fails", async () => {
const cancel = vi.fn();
const result = await openFileInExternalBrowser({
threadRef,
workspaceRoot: "/repo",
filePath: "/repo/artifacts/report.html",
httpBaseUrl: "http://environment.test:1234",
createAssetUrl: vi.fn(async () =>
AsyncResult.failure<AssetCreateUrlResult, Error>(Cause.fail(new Error("missing"))),
),
beginOpen: () => ({ open: vi.fn(), cancel }),
});

expect(result._tag).toBe("Failure");
expect(cancel).toHaveBeenCalledOnce();
});

it("closes the reserved target when signed URL creation rejects", async () => {
const cancel = vi.fn();
const failure = new Error("connection closed");
await expect(
openFileInExternalBrowser({
threadRef,
workspaceRoot: "/repo",
filePath: "/repo/artifacts/report.html",
httpBaseUrl: "http://environment.test:1234",
createAssetUrl: vi.fn(async () => Promise.reject(failure)),
beginOpen: () => ({ open: vi.fn(), cancel }),
}),
).rejects.toBe(failure);
expect(cancel).toHaveBeenCalledOnce();
});

it("closes the reserved target when navigation fails", async () => {
const cancel = vi.fn();
const failure = new Error("blocked");
await expect(
openFileInExternalBrowser({
threadRef,
workspaceRoot: "/repo",
filePath: "/repo/artifacts/report.pdf",
httpBaseUrl: "http://environment.test:1234",
createAssetUrl: vi.fn(async () =>
AsyncResult.success(assetResult("/api/assets/token/report.pdf")),
),
beginOpen: () => ({
open: vi.fn(async () => Promise.reject(failure)),
cancel,
}),
}),
).rejects.toBe(failure);
expect(cancel).toHaveBeenCalledOnce();
});
});
112 changes: 88 additions & 24 deletions apps/web/src/browser/openFileInPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ export type OpenPreviewMutation<E = unknown> = (input: {
readonly input: PreviewOpenInput;
}) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>;

export interface ExternalFileOpenSession {
readonly open: (url: string) => Promise<void>;
readonly cancel: () => void;
}

export function beginExternalFileOpen(input: {
readonly isDesktop: boolean;
readonly openExternal: (url: string) => Promise<void>;
readonly openWindow: () => Pick<Window, "close" | "location" | "opener"> | null;
}): ExternalFileOpenSession {
if (input.isDesktop) {
return { open: input.openExternal, cancel: () => undefined };
}
const tab = input.openWindow();
if (!tab) throw new Error("The browser blocked the new tab.");
tab.opener = null;
return {
open: async (url) => tab.location.replace(url),
cancel: () => tab.close(),
};
}

export async function openUrlInPreview<E>(input: {
readonly threadRef: ScopedThreadRef;
readonly url: string;
Expand Down Expand Up @@ -81,35 +103,22 @@ export async function openUrlInPreview<E>(input: {
});
}

type CreateAssetUrl<E> = (input: {
readonly environmentId: EnvironmentId;
readonly input: { readonly resource: AssetResource };
}) => Promise<AtomCommandResult<AssetCreateUrlResult, E>>;

/**
* Opens a browser document in the integrated browser. Inside the workspace the
* page may load sibling assets; a file outside it is served on its own.
* Signs a file for the browser. Inside the workspace the page may load sibling
* assets; a file outside it is served on its own.
*/
export async function openFileInPreview<AssetError, PreviewError>(input: {
async function createFileAssetUrl<E>(input: {
readonly threadRef: ScopedThreadRef;
readonly filePath: string;
readonly workspaceRoot: string | undefined;
readonly httpBaseUrl: string;
readonly createAssetUrl: (input: {
readonly environmentId: EnvironmentId;
readonly input: { readonly resource: AssetResource };
}) => Promise<AtomCommandResult<AssetCreateUrlResult, AssetError>>;
readonly openPreview: OpenPreviewMutation<PreviewError>;
}): Promise<
AtomCommandResult<
void,
AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError
>
> {
if (!isPreviewSupportedInRuntime()) {
return AsyncResult.failure(
Cause.fail(
new BrowserPreviewUnavailableError({
message: "The integrated browser is unavailable in this runtime.",
}),
),
);
}
readonly createAssetUrl: CreateAssetUrl<E>;
}): Promise<AtomCommandResult<string, E>> {
const insideWorkspace =
mediaFileReference(input.filePath, input.workspaceRoot).relativePath !== undefined;
const assetResult = await input.createAssetUrl({
Expand All @@ -131,9 +140,64 @@ export async function openFileInPreview<AssetError, PreviewError>(input: {
Cause.die(new Error("The environment returned an invalid asset URL.")),
);
}
return AsyncResult.success(assetUrl);
}

/** Opens a browser document in the integrated browser. */
export async function openFileInPreview<AssetError, PreviewError>(input: {
readonly threadRef: ScopedThreadRef;
readonly filePath: string;
readonly workspaceRoot: string | undefined;
readonly httpBaseUrl: string;
readonly createAssetUrl: CreateAssetUrl<AssetError>;
readonly openPreview: OpenPreviewMutation<PreviewError>;
}): Promise<
AtomCommandResult<
void,
AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError
>
> {
if (!isPreviewSupportedInRuntime()) {
return AsyncResult.failure(
Cause.fail(
new BrowserPreviewUnavailableError({
message: "The integrated browser is unavailable in this runtime.",
}),
),
);
}
const assetUrl = await createFileAssetUrl(input);
if (assetUrl._tag === "Failure") {
return AsyncResult.failure(assetUrl.cause);
}
return openUrlInPreview({
threadRef: input.threadRef,
url: assetUrl,
url: assetUrl.value,
openPreview: input.openPreview,
});
}

/** Opens a browser document in a new system browser tab (web) or the default browser (desktop). */
export async function openFileInExternalBrowser<AssetError>(input: {
readonly threadRef: ScopedThreadRef;
readonly filePath: string;
readonly workspaceRoot: string | undefined;
readonly httpBaseUrl: string;
readonly createAssetUrl: CreateAssetUrl<AssetError>;
/** Runs before the first await so a web click can reserve its tab synchronously. */
readonly beginOpen: () => ExternalFileOpenSession;
}): Promise<AtomCommandResult<void, AssetError>> {
const session = input.beginOpen();
try {
const assetUrl = await createFileAssetUrl(input);
if (assetUrl._tag === "Failure") {
session.cancel();
return AsyncResult.failure(assetUrl.cause);
}
await session.open(assetUrl.value);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return AsyncResult.success(undefined);
} catch (cause) {
session.cancel();
throw cause;
}
}
Loading
Loading