| author | |
| committer | |
| log | 50767683b396cf7065bb18072d6d14c05475b6e9 |
| tree | aa37296358fc59618834398e02ff6eb423cc2306 |
| parent | d1339f38462c206d65d3c8d8396c1ddfdfbb807c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>2 files changed, 179 insertions(+), 1 deletions(-)
apps/web/src/components/preview/openTerminalLinkInPreview.test.ts created+130| ... | @@ -0,0 +1,130 @@ | ||
| 1 | import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | openTerminalLinkInPreview, | ||
| 8 | TerminalLinkContextMenuShowError, | ||
| 9 | TerminalLinkPreviewOpenError, | ||
| 10 | } from "./openTerminalLinkInPreview"; | ||
| 11 | |||
| 12 | vi.mock("~/previewStateStore", () => ({ | ||
| 13 | applyPreviewServerSnapshot: vi.fn(), | ||
| 14 | isPreviewSupportedInRuntime: () => true, | ||
| 15 | })); | ||
| 16 | |||
| 17 | vi.mock("~/rightPanelStore", () => ({ | ||
| 18 | useRightPanelStore: { | ||
| 19 | getState: () => ({ openBrowser: vi.fn() }), | ||
| 20 | }, | ||
| 21 | })); | ||
| 22 | |||
| 23 | const threadRef = { | ||
| 24 | environmentId: "local" as ScopedThreadRef["environmentId"], | ||
| 25 | threadId: "thread-1" as ScopedThreadRef["threadId"], | ||
| 26 | }; | ||
| 27 | |||
| 28 | const snapshot: PreviewSessionSnapshot = { | ||
| 29 | threadId: threadRef.threadId, | ||
| 30 | tabId: "tab-1", | ||
| 31 | navStatus: { _tag: "Idle" }, | ||
| 32 | canGoBack: false, | ||
| 33 | canGoForward: false, | ||
| 34 | updatedAt: "2026-06-20T00:00:00.000Z", | ||
| 35 | }; | ||
| 36 | |||
| 37 | afterEach(() => { | ||
| 38 | vi.restoreAllMocks(); | ||
| 39 | }); | ||
| 40 | |||
| 41 | describe("openTerminalLinkInPreview", () => { | ||
| 42 | it("preserves context-menu failures with terminal link context before falling back", async () => { | ||
| 43 | const cause = new Error("menu unavailable"); | ||
| 44 | const fallbackToBrowser = vi.fn(); | ||
| 45 | const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); | ||
| 46 | const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| 47 | |||
| 48 | await openTerminalLinkInPreview({ | ||
| 49 | url: "http://localhost:3000/path?token=secret", | ||
| 50 | position: { x: 12, y: 34 }, | ||
| 51 | threadRef, | ||
| 52 | openPreview, | ||
| 53 | localApi: { | ||
| 54 | contextMenu: { | ||
| 55 | show: vi.fn(async () => { | ||
| 56 | throw cause; | ||
| 57 | }), | ||
| 58 | }, | ||
| 59 | } as unknown as LocalApi, | ||
| 60 | fallbackToBrowser, | ||
| 61 | }); | ||
| 62 | |||
| 63 | expect(fallbackToBrowser).toHaveBeenCalledOnce(); | ||
| 64 | expect(openPreview).not.toHaveBeenCalled(); | ||
| 65 | expect(reportError).toHaveBeenCalledOnce(); | ||
| 66 | const error = reportError.mock.calls[0]?.[0]; | ||
| 67 | expect(error).toBeInstanceOf(TerminalLinkContextMenuShowError); | ||
| 68 | expect(error).toMatchObject({ | ||
| 69 | environmentId: "local", | ||
| 70 | threadId: "thread-1", | ||
| 71 | targetOrigin: "http://localhost:3000", | ||
| 72 | cause, | ||
| 73 | }); | ||
| 74 | expect(error.message).not.toContain("menu unavailable"); | ||
| 75 | expect(error.targetOrigin).not.toContain("secret"); | ||
| 76 | }); | ||
| 77 | |||
| 78 | it("preserves the complete preview failure cause before falling back", async () => { | ||
| 79 | const rpcError = new Error("preview unavailable"); | ||
| 80 | const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect")); | ||
| 81 | const fallbackToBrowser = vi.fn(); | ||
| 82 | const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| 83 | |||
| 84 | await openTerminalLinkInPreview({ | ||
| 85 | url: "http://127.0.0.1:5173/", | ||
| 86 | position: { x: 12, y: 34 }, | ||
| 87 | threadRef, | ||
| 88 | openPreview: async () => AsyncResult.failure(cause), | ||
| 89 | localApi: { | ||
| 90 | contextMenu: { | ||
| 91 | show: vi.fn(async () => "open-in-preview"), | ||
| 92 | }, | ||
| 93 | } as unknown as LocalApi, | ||
| 94 | fallbackToBrowser, | ||
| 95 | }); | ||
| 96 | |||
| 97 | expect(fallbackToBrowser).toHaveBeenCalledOnce(); | ||
| 98 | expect(reportError).toHaveBeenCalledOnce(); | ||
| 99 | const error = reportError.mock.calls[0]?.[0]; | ||
| 100 | expect(error).toBeInstanceOf(TerminalLinkPreviewOpenError); | ||
| 101 | expect(error).toMatchObject({ | ||
| 102 | environmentId: "local", | ||
| 103 | threadId: "thread-1", | ||
| 104 | targetOrigin: "http://127.0.0.1:5173", | ||
| 105 | cause, | ||
| 106 | }); | ||
| 107 | expect(error.message).not.toContain("preview unavailable"); | ||
| 108 | }); | ||
| 109 | |||
| 110 | it("does not report or fall back when opening the preview is interrupted", async () => { | ||
| 111 | const fallbackToBrowser = vi.fn(); | ||
| 112 | const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| 113 | |||
| 114 | await openTerminalLinkInPreview({ | ||
| 115 | url: "http://localhost:5173/", | ||
| 116 | position: { x: 12, y: 34 }, | ||
| 117 | threadRef, | ||
| 118 | openPreview: async () => AsyncResult.failure(Cause.interrupt()), | ||
| 119 | localApi: { | ||
| 120 | contextMenu: { | ||
| 121 | show: vi.fn(async () => "open-in-preview"), | ||
| 122 | }, | ||
| 123 | } as unknown as LocalApi, | ||
| 124 | fallbackToBrowser, | ||
| 125 | }); | ||
| 126 | |||
| 127 | expect(reportError).not.toHaveBeenCalled(); | ||
| 128 | expect(fallbackToBrowser).not.toHaveBeenCalled(); | ||
| 129 | }); | ||
| 130 | }); | ||
apps/web/src/components/preview/openTerminalLinkInPreview.ts+49-1| ... | @@ -1,10 +1,37 @@ | ... | @@ -1,10 +1,37 @@ |
| 1 | import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | import { isPreviewableUrl } from "@t3tools/shared/preview"; | 3 | import { isPreviewableUrl } from "@t3tools/shared/preview"; |
| 4 | import * as Schema from "effect/Schema"; | ||
| 3 | 5 | ||
| 4 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; | 6 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; |
| 5 | import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; | 7 | import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; |
| 6 | import { useRightPanelStore } from "~/rightPanelStore"; | 8 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 7 | 9 | ||
| 10 | const terminalLinkErrorContext = { | ||
| 11 | environmentId: Schema.String, | ||
| 12 | threadId: Schema.String, | ||
| 13 | targetOrigin: Schema.String, | ||
| 14 | cause: Schema.Defect(), | ||
| 15 | }; | ||
| 16 | |||
| 17 | export class TerminalLinkContextMenuShowError extends Schema.TaggedErrorClass<TerminalLinkContextMenuShowError>()( | ||
| 18 | "TerminalLinkContextMenuShowError", | ||
| 19 | terminalLinkErrorContext, | ||
| 20 | ) { | ||
| 21 | override get message(): string { | ||
| 22 | return `Failed to show the context menu for terminal link ${this.targetOrigin}.`; | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass<TerminalLinkPreviewOpenError>()( | ||
| 27 | "TerminalLinkPreviewOpenError", | ||
| 28 | terminalLinkErrorContext, | ||
| 29 | ) { | ||
| 30 | override get message(): string { | ||
| 31 | return `Failed to open terminal link ${this.targetOrigin} in preview for thread ${this.threadId}.`; | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 8 | interface OpenTerminalLinkInPreviewInput<E> { | 35 | interface OpenTerminalLinkInPreviewInput<E> { |
| 9 | readonly url: string; | 36 | readonly url: string; |
| 10 | readonly position: { x: number; y: number }; | 37 | readonly position: { x: number; y: number }; |
| ... | @@ -27,6 +54,12 @@ export async function openTerminalLinkInPreview<E>( | ... | @@ -27,6 +54,12 @@ export async function openTerminalLinkInPreview<E>( |
| 27 | return; | 54 | return; |
| 28 | } | 55 | } |
| 29 | 56 | ||
| 57 | const errorContext = { | ||
| 58 | environmentId: input.threadRef.environmentId, | ||
| 59 | threadId: input.threadRef.threadId, | ||
| 60 | targetOrigin: new URL(input.url).origin, | ||
| 61 | }; | ||
| 62 | |||
| 30 | let choice: "open-in-preview" | "open-in-browser" | null; | 63 | let choice: "open-in-preview" | "open-in-browser" | null; |
| 31 | try { | 64 | try { |
| 32 | choice = await input.localApi.contextMenu.show( | 65 | choice = await input.localApi.contextMenu.show( |
| ... | @@ -36,7 +69,13 @@ export async function openTerminalLinkInPreview<E>( | ... | @@ -36,7 +69,13 @@ export async function openTerminalLinkInPreview<E>( |
| 36 | ], | 69 | ], |
| 37 | input.position, | 70 | input.position, |
| 38 | ); | 71 | ); |
| 39 | } catch { | 72 | } catch (cause) { |
| 73 | console.error( | ||
| 74 | new TerminalLinkContextMenuShowError({ | ||
| 75 | ...errorContext, | ||
| 76 | cause, | ||
| 77 | }), | ||
| 78 | ); | ||
| 40 | input.fallbackToBrowser(); | 79 | input.fallbackToBrowser(); |
| 41 | return; | 80 | return; |
| 42 | } | 81 | } |
| ... | @@ -47,6 +86,15 @@ export async function openTerminalLinkInPreview<E>( | ... | @@ -47,6 +86,15 @@ export async function openTerminalLinkInPreview<E>( |
| 47 | input: { threadId: input.threadRef.threadId, url: input.url }, | 86 | input: { threadId: input.threadRef.threadId, url: input.url }, |
| 48 | }); | 87 | }); |
| 49 | if (result._tag === "Failure") { | 88 | if (result._tag === "Failure") { |
| 89 | if (isAtomCommandInterrupted(result)) { | ||
| 90 | return; | ||
| 91 | } | ||
| 92 | console.error( | ||
| 93 | new TerminalLinkPreviewOpenError({ | ||
| 94 | ...errorContext, | ||
| 95 | cause: result.cause, | ||
| 96 | }), | ||
| 97 | ); | ||
| 50 | input.fallbackToBrowser(); | 98 | input.fallbackToBrowser(); |
| 51 | return; | 99 | return; |
| 52 | } | 100 | } |