diff --git a/packages/app/src/web/actions-skiller.ts b/packages/app/src/web/actions-skiller.ts index 0f0fde75..b528805f 100644 --- a/packages/app/src/web/actions-skiller.ts +++ b/packages/app/src/web/actions-skiller.ts @@ -1,8 +1,8 @@ import { type BrowserActionContext, withBusy } from "./actions-shared.js" import { openSkiller } from "./api.js" -import { openUrl } from "./open-url.js" +import { type PreparedOpenUrl, prepareOpenUrl } from "./open-url.js" -type SkillerLaunch = { +export type SkillerLaunch = { readonly alreadyRunning: boolean readonly appPath: string readonly logPath: string @@ -14,7 +14,7 @@ type SkillerLaunch = { readonly trpcBasePath: string } -const skillerLaunchMessage = (launch: SkillerLaunch, openedPath: string, opened: boolean): string => { +export const skillerLaunchMessage = (launch: SkillerLaunch, openedPath: string, opened: boolean): string => { const pid = launch.pid === null ? "unknown pid" : `pid ${launch.pid}` const state = launch.alreadyRunning ? `Skiller is already running (${pid}). Log: ${launch.logPath}` @@ -27,20 +27,29 @@ const skillerLaunchMessage = (launch: SkillerLaunch, openedPath: string, opened: : `${state}.${scope} Popup was blocked. Open ${openedPath} manually.` } +export const openPreparedSkillerLaunch = (launch: SkillerLaunch, preparedUrl: PreparedOpenUrl): string => { + const openedPath = launch.appPath + const opened = preparedUrl.navigate(openedPath) + return skillerLaunchMessage(launch, openedPath, opened) +} + export const openSkillerApp = ( context: BrowserActionContext, projectKey: string | null | undefined = context.selectedProjectKey, sessionId?: string ): void => { const resolvedProjectKey = projectKey ?? undefined + const preparedUrl = prepareOpenUrl() + context.setMessage("Opening Skiller...") withBusy({ context, effect: openSkiller(resolvedProjectKey, sessionId), label: "Opening Skiller", + onFailure: () => { + preparedUrl.close() + }, onSuccess: (launch) => { - const openedPath = launch.appPath - const opened = openUrl(openedPath) - context.setMessage(skillerLaunchMessage(launch, openedPath, opened)) + context.setMessage(openPreparedSkillerLaunch(launch, preparedUrl)) } }) } diff --git a/packages/app/src/web/app-ready-layout.tsx b/packages/app/src/web/app-ready-layout.tsx index 4c4cdbc1..814c1cbe 100644 --- a/packages/app/src/web/app-ready-layout.tsx +++ b/packages/app/src/web/app-ready-layout.tsx @@ -201,6 +201,27 @@ const StatusHeader = ( ) +const TerminalWorkspaceStatus = ( + { busyLabel, message }: Pick +): JSX.Element | null => + busyLabel === null && message === null + ? null + : ( + + + {message === null ? null : message: {message}} + + ) + export const ReadyLayout = ({ busyLabel, message, ...props }: ReadyLayoutProps): JSX.Element => ( hasVisibleTerminalWorkspace(props) ? ( @@ -212,6 +233,7 @@ export const ReadyLayout = ({ busyLabel, message, ...props }: ReadyLayoutProps): padding={terminalWorkspacePadding(props.viewportLayout)} width="100%" > + ) diff --git a/packages/app/src/web/app-ready-url.ts b/packages/app/src/web/app-ready-url.ts index be550e37..845f0621 100644 --- a/packages/app/src/web/app-ready-url.ts +++ b/packages/app/src/web/app-ready-url.ts @@ -124,7 +124,7 @@ const resolveProjectId = ( return project?.id ?? null } -const activeScreenFromMenu = (menu: BrowserMenuTag, outputRequested: boolean): BrowserScreen => { +export const activeScreenFromMenu = (menu: BrowserMenuTag, outputRequested: boolean): BrowserScreen => { if (outputRequested && (menu === "Logs" || menu === "Status")) { return outputScreen() } diff --git a/packages/app/src/web/app-terminal-session-handlers.ts b/packages/app/src/web/app-terminal-session-handlers.ts index 091fd9f5..1bf2022a 100644 --- a/packages/app/src/web/app-terminal-session-handlers.ts +++ b/packages/app/src/web/app-terminal-session-handlers.ts @@ -1,6 +1,7 @@ import { Effect } from "effect" import { type Dispatch, type SetStateAction, useCallback, useState } from "react" +import { openPreparedSkillerLaunch } from "./actions-skiller.js" import { applyProject, type ContainerTaskSnapshot, @@ -8,12 +9,13 @@ import { loadProjectBrowser, loadProjectTaskLogs, loadProjectTasks, + openSkiller, projectBrowserCdpUrl, projectBrowserNoVncUrl, type ProjectBrowserSession, stopProjectTask } from "./api.js" -import { openUrl } from "./open-url.js" +import { openUrl, prepareOpenUrl } from "./open-url.js" import { projectSshRoutePath } from "./terminal.js" export type StateMessageUpdater = (message: string | null) => void @@ -21,6 +23,7 @@ export type StateMessageUpdater = (message: string | null) => void export type ProjectHandlers = { readonly onApplyProject: (() => void) | undefined readonly onOpenBrowser: (() => void) | undefined + readonly onOpenSkiller: (() => void) | undefined readonly onOpenTaskManager: (() => void) | undefined readonly onOpenTerminal: (() => void) | undefined } @@ -116,29 +119,89 @@ const runOpenTerminal = (projectKey: string, setMessage: StateMessageUpdater): v ) } +const runOpenSkiller = ( + projectKey: string, + terminalSessionId: string, + setMessage: StateMessageUpdater +): void => { + const preparedUrl = prepareOpenUrl() + setMessage("Opening Skiller...") + void Effect.runPromise( + openSkiller(projectKey, terminalSessionId).pipe( + Effect.match({ + onFailure: (error) => { + preparedUrl.close() + setMessage(`Failed to open Skiller: ${error}`) + }, + onSuccess: (launch) => { + setMessage(openPreparedSkillerLaunch(launch, preparedUrl)) + } + }) + ) + ) +} + export type ProjectActionHandlersArgs = { readonly onOpenTaskManagerRequest: () => void readonly projectId: string | undefined readonly projectKey: string | undefined readonly projectLabel: string readonly setMessage: StateMessageUpdater + readonly terminalSessionId: string | undefined } -export const useProjectActionHandlers = ( - { onOpenTaskManagerRequest, projectId, projectKey, projectLabel, setMessage }: ProjectActionHandlersArgs -): ProjectHandlers => ({ - onApplyProject: projectId === undefined ? undefined : () => { - runApplyProject(projectId, projectLabel, setMessage) - }, - onOpenBrowser: projectId === undefined ? undefined : () => { - runOpenBrowser(projectId, setMessage) - }, - onOpenTaskManager: projectId === undefined ? undefined : onOpenTaskManagerRequest, - onOpenTerminal: projectId === undefined || projectKey === undefined +const projectAction = ( + projectId: string | undefined, + action: (projectId: string) => void +): (() => void) | undefined => + projectId === undefined + ? undefined + : () => { + action(projectId) + } + +const projectKeyAction = ( + projectId: string | undefined, + projectKey: string | undefined, + action: (projectKey: string) => void +): (() => void) | undefined => + projectId === undefined || projectKey === undefined + ? undefined + : () => { + action(projectKey) + } + +const projectTerminalAction = ( + projectId: string | undefined, + projectKey: string | undefined, + terminalSessionId: string | undefined, + action: (projectKey: string, terminalSessionId: string) => void +): (() => void) | undefined => + projectId === undefined || projectKey === undefined || terminalSessionId === undefined ? undefined : () => { - runOpenTerminal(projectKey, setMessage) + action(projectKey, terminalSessionId) } + +export const useProjectActionHandlers = ( + { onOpenTaskManagerRequest, projectId, projectKey, projectLabel, setMessage, terminalSessionId }: + ProjectActionHandlersArgs +): ProjectHandlers => ({ + onApplyProject: projectAction(projectId, (resolvedProjectId) => { + runApplyProject(resolvedProjectId, projectLabel, setMessage) + }), + onOpenBrowser: projectAction(projectId, (resolvedProjectId) => { + runOpenBrowser(resolvedProjectId, setMessage) + }), + onOpenSkiller: projectTerminalAction(projectId, projectKey, terminalSessionId, (resolvedProjectKey, sessionId) => { + runOpenSkiller(resolvedProjectKey, sessionId, setMessage) + }), + onOpenTaskManager: projectAction(projectId, () => { + onOpenTaskManagerRequest() + }), + onOpenTerminal: projectKeyAction(projectId, projectKey, (resolvedProjectKey) => { + runOpenTerminal(resolvedProjectKey, setMessage) + }) }) const runRefreshTasks = ( diff --git a/packages/app/src/web/app-terminal-session-ui.tsx b/packages/app/src/web/app-terminal-session-ui.tsx index 9758ff95..f076a87f 100644 --- a/packages/app/src/web/app-terminal-session-ui.tsx +++ b/packages/app/src/web/app-terminal-session-ui.tsx @@ -133,6 +133,7 @@ export const TerminalOnlyTerminalPanel = ( onKill={callbacks.onKill} onMessage={callbacks.onMessage} onOpenBrowser={handlers.onOpenBrowser} + onOpenSkiller={handlers.onOpenSkiller} onOpenTaskManager={handlers.onOpenTaskManager} onOpenTerminal={handlers.onOpenTerminal} session={session} diff --git a/packages/app/src/web/app-terminal-session.tsx b/packages/app/src/web/app-terminal-session.tsx index 1957926b..c75faecf 100644 --- a/packages/app/src/web/app-terminal-session.tsx +++ b/packages/app/src/web/app-terminal-session.tsx @@ -141,7 +141,8 @@ const useTerminalOnlyReadyState = ( projectId, projectKey, projectLabel, - setMessage + setMessage, + terminalSessionId: session.session.id }) return { handlers, project, setMessage, setTaskManagerOpen, taskManagerOpen, tasks } } diff --git a/packages/app/src/web/menu.ts b/packages/app/src/web/menu.ts index e358eb41..ab874266 100644 --- a/packages/app/src/web/menu.ts +++ b/packages/app/src/web/menu.ts @@ -20,7 +20,7 @@ export type BrowserMenuTag = | "Delete" | "Quit" -const browserMenuOrder: ReadonlyArray = [ +export const browserMenuOrder: ReadonlyArray = [ "Create", "Select", "Auth", diff --git a/packages/app/src/web/open-url.ts b/packages/app/src/web/open-url.ts index 6b522043..78e967b4 100644 --- a/packages/app/src/web/open-url.ts +++ b/packages/app/src/web/open-url.ts @@ -1,3 +1,8 @@ +export type PreparedOpenUrl = { + readonly close: () => void + readonly navigate: (url: string) => boolean +} + export const openUrl = (url: string): boolean => { if (typeof globalThis.open === "function") { const openedWindow = globalThis.open(url, "_blank", "noopener") @@ -5,3 +10,29 @@ export const openUrl = (url: string): boolean => { } return false } + +const blockedPreparedOpenUrl = (): PreparedOpenUrl => ({ + close: () => {}, + navigate: openUrl +}) + +export const prepareOpenUrl = (): PreparedOpenUrl => { + if (typeof globalThis.open !== "function") { + return blockedPreparedOpenUrl() + } + const openedWindow = globalThis.open("about:blank", "_blank", "noopener") + if (openedWindow === null) { + return blockedPreparedOpenUrl() + } + openedWindow.opener = null + return { + close: () => { + openedWindow.close() + }, + navigate: (url) => { + openedWindow.location.href = url + openedWindow.focus() + return true + } + } +} diff --git a/packages/app/src/web/screen.ts b/packages/app/src/web/screen.ts index 5bdb8f80..af57aa26 100644 --- a/packages/app/src/web/screen.ts +++ b/packages/app/src/web/screen.ts @@ -32,7 +32,7 @@ export const outputScreen = (): BrowserScreen => ({ tag: "Output" }) export const projectPickerScreen = (): BrowserScreen => ({ tag: "ProjectPicker" }) -const projectMenuTags: ReadonlySet = new Set([ +export const browserProjectMenuTags: ReadonlyArray = [ "Browser", "Databases", "Delete", @@ -46,7 +46,9 @@ const projectMenuTags: ReadonlySet = new Set([ "Skills", "Status", "Tasks" -]) +] + +const projectMenuTags: ReadonlySet = new Set(browserProjectMenuTags) export const isProjectMenu = (menu: BrowserMenuTag): menu is BrowserProjectMenuTag => projectMenuTags.has(menu) diff --git a/packages/app/tests/docker-git/actions-skiller.test.ts b/packages/app/tests/docker-git/actions-skiller.test.ts index 9f4c6029..ba23515b 100644 --- a/packages/app/tests/docker-git/actions-skiller.test.ts +++ b/packages/app/tests/docker-git/actions-skiller.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" -import { beforeEach, vi } from "vitest" +import { afterEach, beforeEach, vi } from "vitest" import { openSkillerApp } from "../../src/web/actions-skiller.js" import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" +import { type BrowserOpenMockWindow, makeBrowserOpenMockWindow, stubBrowserOpen } from "./browser-open-fixture.js" const openSkillerMock = vi.hoisted(() => vi.fn()) -const openUrlMock = vi.hoisted(() => vi.fn()) const proofScope = { containerCodexSkillsPath: "/home/dev/.codex/skills", @@ -54,7 +54,6 @@ const skillerLaunch = ( }) const mockScopedSkillerLaunch = (): void => { - openUrlMock.mockReturnValue(true) openSkillerMock.mockImplementation(() => Effect.succeed(skillerLaunch({ alreadyRunning: true, @@ -67,28 +66,35 @@ vi.mock("../../src/web/api.js", () => ({ openSkiller: openSkillerMock })) -vi.mock("../../src/web/open-url.js", () => ({ - openUrl: openUrlMock -})) - describe("web Skiller actions", () => { + let openedWindow: BrowserOpenMockWindow = makeBrowserOpenMockWindow() + let browserOpenMock: ReturnType = vi.fn() + beforeEach(() => { vi.clearAllMocks() + openedWindow = makeBrowserOpenMockWindow() + browserOpenMock = stubBrowserOpen(openedWindow) + }) + + afterEach(() => { + vi.unstubAllGlobals() }) it.effect("opens Skiller through the docker-git API", () => Effect.gen(function*(_) { - openUrlMock.mockReturnValue(true) openSkillerMock.mockImplementation(() => Effect.succeed(skillerLaunch())) const { context, setMessage } = makeBrowserActionContext() openSkillerApp(context) + expect(browserOpenMock).toHaveBeenCalledWith("about:blank", "_blank", "noopener") + expect(setMessage).toHaveBeenCalledWith("Opening Skiller...") yield* _(waitForAssertion(() => { expect(openSkillerMock).toHaveBeenCalledWith(undefined, undefined) })) yield* _(waitForAssertion(() => { - expect(openUrlMock).toHaveBeenCalledWith("/api/skiller/app/") + expect(openedWindow.location.href).toBe("/api/skiller/app/") + expect(openedWindow.focus).toHaveBeenCalledOnce() expect(setMessage).toHaveBeenCalledWith( "Skiller launch started (pid 1234). Log: /home/dev/.docker-git/logs/skiller.log. Opened /api/skiller/app/." ) @@ -119,7 +125,6 @@ describe("web Skiller actions", () => { let completeLaunch = (_launch: ReturnType): void => { throw new Error("Expected Skiller launch effect to be subscribed.") } - openUrlMock.mockReturnValue(true) openSkillerMock.mockImplementation(() => Effect.async>((resume) => { completeLaunch = (launch) => { @@ -131,7 +136,8 @@ describe("web Skiller actions", () => { openSkillerApp(context, "abc123", "terminal-proof") - expect(openUrlMock).not.toHaveBeenCalled() + expect(browserOpenMock).toHaveBeenCalledOnce() + expect(openedWindow.location.href).toBe("") yield* _(waitForAssertion(() => { expect(openSkillerMock).toHaveBeenCalledWith("abc123", "terminal-proof") })) @@ -142,11 +148,25 @@ describe("web Skiller actions", () => { trpcBasePath: "/api/ssh/session/terminal-proof/skiller" })) yield* _(waitForAssertion(() => { - expect(openUrlMock).toHaveBeenCalledWith("/api/ssh/session/terminal-proof/skiller/app/") + expect(openedWindow.location.href).toBe("/api/ssh/session/terminal-proof/skiller/app/") expect(setMessage).toHaveBeenCalledWith( "Skiller is already running (pid 1234). Log: /home/dev/.docker-git/logs/skiller.log. Container FS: dg-project:/home/dev/app. Opened /api/ssh/session/terminal-proof/skiller/app/." ) })) - expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openedWindow.focus).toHaveBeenCalledOnce() + })) + + it.effect("closes the prepared Skiller popup when launch fails", () => + Effect.gen(function*(_) { + openSkillerMock.mockImplementation(() => Effect.fail("Skiller failed")) + const { context, setMessage } = makeBrowserActionContext() + + openSkillerApp(context, "abc123", "terminal-proof") + + yield* _(waitForAssertion(() => { + expect(openedWindow.close).toHaveBeenCalledOnce() + expect(openedWindow.location.href).toBe("") + expect(setMessage).toHaveBeenCalledWith("Skiller failed") + })) })) }) diff --git a/packages/app/tests/docker-git/app-ready-url.test.ts b/packages/app/tests/docker-git/app-ready-url.test.ts index 6821ba55..531a7ae6 100644 --- a/packages/app/tests/docker-git/app-ready-url.test.ts +++ b/packages/app/tests/docker-git/app-ready-url.test.ts @@ -1,7 +1,22 @@ +import * as fc from "fast-check" import { describe, expect, it } from "vitest" import type { DashboardData } from "../../src/web/api.js" -import { parseReadyUrlNavigation, readyUrlPath } from "../../src/web/app-ready-url.js" +import { activeScreenFromMenu, parseReadyUrlNavigation, readyUrlPath } from "../../src/web/app-ready-url.js" +import { browserMenuOrder, type BrowserMenuTag } from "../../src/web/menu.js" +import { browserProjectMenuTags, isProjectMenu, menuScreen, outputScreen } from "../../src/web/screen.js" + +type ReadyUrlPathInput = Parameters[0] +type ParsedReadyNavigation = NonNullable> + +type ProjectSelection = Pick & { + readonly expectedSelectedProjectId: string | null +} + +type ReadyUrlRoundTripCase = { + readonly expected: ParsedReadyNavigation + readonly state: ReadyUrlPathInput +} const dashboard: DashboardData = { apiBaseUrl: "/api", @@ -30,6 +45,101 @@ const dashboard: DashboardData = { const selectedProjectSummary = dashboard.projects[0] +const projectActionMenuTags = browserProjectMenuTags.filter((menu) => menu !== "Select") +const nonProjectMenuTags = browserMenuOrder.filter((menu) => !isProjectMenu(menu)) + +const emptyProjectSelection: ProjectSelection = { + expectedSelectedProjectId: null, + selectedProjectId: null, + selectedProjectSummary: undefined +} + +const selectedProjectSelection: ProjectSelection = { + expectedSelectedProjectId: "project-1", + selectedProjectId: "project-1", + selectedProjectSummary +} + +const projectSelectionArbitrary: fc.Arbitrary = fc.constantFrom( + emptyProjectSelection, + selectedProjectSelection +) + +const projectActionCaseArbitrary = fc.oneof( + fc.tuple(fc.constantFrom(...projectActionMenuTags), projectSelectionArbitrary), + fc.tuple(fc.constant("Select"), fc.constant(selectedProjectSelection)) +) + +const readyUrlMenuRoundTripArbitrary: fc.Arbitrary = fc.constantFrom(...browserMenuOrder) + .map((menu) => ({ + expected: { + activeScreen: menuScreen(), + menu, + projectNavigationArmed: false, + selectedProjectId: null + }, + state: { + activeScreen: menuScreen(), + activeTerminalSession: null, + currentMenu: menu, + selectedProjectId: null, + selectedProjectSummary: undefined + } + })) + +const readyUrlActionRoundTripArbitrary: fc.Arbitrary = fc.oneof( + fc.constantFrom(...nonProjectMenuTags).map((menu) => ({ + expected: { + activeScreen: activeScreenFromMenu(menu, false), + menu, + projectNavigationArmed: false, + selectedProjectId: null + }, + state: { + activeScreen: activeScreenFromMenu(menu, false), + activeTerminalSession: null, + currentMenu: menu, + selectedProjectId: null, + selectedProjectSummary: undefined + } + })), + projectActionCaseArbitrary.map(([menu, selection]) => ({ + expected: { + activeScreen: activeScreenFromMenu(menu, false), + menu, + projectNavigationArmed: false, + selectedProjectId: selection.expectedSelectedProjectId + }, + state: { + activeScreen: activeScreenFromMenu(menu, false), + activeTerminalSession: null, + currentMenu: menu, + selectedProjectId: selection.selectedProjectId, + selectedProjectSummary: selection.selectedProjectSummary + } + })), + fc.tuple(fc.constantFrom("Logs", "Status"), projectSelectionArbitrary).map(([menu, selection]) => ({ + expected: { + activeScreen: outputScreen(), + menu, + projectNavigationArmed: false, + selectedProjectId: selection.expectedSelectedProjectId + }, + state: { + activeScreen: outputScreen(), + activeTerminalSession: null, + currentMenu: menu, + selectedProjectId: selection.selectedProjectId, + selectedProjectSummary: selection.selectedProjectSummary + } + })) +) + +const readyUrlRoundTripArbitrary: fc.Arbitrary = fc.oneof( + readyUrlMenuRoundTripArbitrary, + readyUrlActionRoundTripArbitrary +) + describe("app ready URL state", () => { it("renders menu tab highlights as copyable URLs", () => { expect(readyUrlPath({ @@ -105,14 +215,14 @@ describe("app ready URL state", () => { })).toBe("/ssh/octocat/hello-world?t=session-1") }) - it("renders SSH project selection as a project terminal list deep link", () => { + it("renders Select project deep link for non-terminal sessions", () => { expect(readyUrlPath({ activeScreen: { tag: "ProjectPicker" }, activeTerminalSession: null, currentMenu: "Select", selectedProjectId: "project-1", selectedProjectSummary - })).toBe("/ssh/octocat/hello-world") + })).toBe("/select/octocat/hello-world") }) it("parses project tab URLs back into app navigation state", () => { @@ -126,6 +236,37 @@ describe("app ready URL state", () => { ) }) + it("parses Select project URLs back into app navigation state", () => { + expect(parseReadyUrlNavigation("https://docker-git.local/select/octocat/hello-world", dashboard.projects)).toEqual( + { + activeScreen: { tag: "ProjectPicker" }, + menu: "Select", + projectNavigationArmed: false, + selectedProjectId: "project-1" + } + ) + }) + + /** + * THEOREM (ready URL round-trip): + * For all generated ValidNavigationState values, parsing readyUrlPath(state) + * returns normalize(state), where activeTerminalSession and + * selectedProjectSummary are URL construction inputs, not persisted fields. + */ + it("preserves ready URL round-trip invariants for valid navigation states", () => { + fc.assert( + fc.property(readyUrlRoundTripArbitrary, ({ expected, state }) => { + const path = readyUrlPath(state) + + if (path === null) { + throw new Error("readyUrlPath returned null for a generated valid navigation state") + } + expect(parseReadyUrlNavigation(`https://docker-git.local${path}`, dashboard.projects)).toEqual(expected) + }), + { numRuns: 75 } + ) + }) + it("keeps /ssh links owned by SSH auto-connect flow", () => { expect(parseReadyUrlNavigation("https://docker-git.local/ssh/octocat/hello-world", dashboard.projects)).toBeNull() expect(parseReadyUrlNavigation("https://docker-git.local/?ssh=octocat/hello-world", dashboard.projects)).toBeNull() diff --git a/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts b/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts index 5fe96f7b..0ae83cde 100644 --- a/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts +++ b/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts @@ -1,10 +1,30 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { afterEach, beforeEach, vi } from "vitest" import { newProjectTerminalUrl, type ProjectHandlers, useProjectActionHandlers } from "../../src/web/app-terminal-session-handlers.js" +import { waitForAssertion } from "./browser-action-context-fixture.js" +import { type BrowserOpenMockWindow, makeBrowserOpenMockWindow, stubBrowserOpen } from "./browser-open-fixture.js" + +const terminalApiMocks = vi.hoisted(() => ({ + openSkiller: vi.fn() +})) + +vi.mock("../../src/web/api.js", () => ({ + applyProject: vi.fn(), + createProjectTerminalSession: vi.fn(), + loadProjectBrowser: vi.fn(), + loadProjectTaskLogs: vi.fn(), + loadProjectTasks: vi.fn(), + openSkiller: terminalApiMocks.openSkiller, + projectBrowserCdpUrl: vi.fn(), + projectBrowserNoVncUrl: vi.fn(), + stopProjectTask: vi.fn() +})) const noopMessage = (_message: string | null): void => {} const noopOpenTaskManager = (): void => {} @@ -18,38 +38,106 @@ const buildHandlers = ( projectKey: "octocat/hello-world", projectLabel: "octocat/hello-world", setMessage: noopMessage, + terminalSessionId: "session-1", ...overrides }) +const skillerLaunch = () => ({ + alreadyRunning: false, + appPath: "/api/ssh/session/session-1/skiller/app/", + logPath: "/home/dev/.docker-git/logs/skiller.log", + ok: true, + pid: 1234, + scope: { + containerName: "dg-project", + containerProjectPath: "/home/dev/app" + }, + startedAtIso: "2026-05-09T17:30:00.000Z", + trpcBasePath: "/api/ssh/session/session-1/skiller", + trpcPort: 17_888 +}) + +type ExpectedProjectHandlers = { + readonly apply: boolean + readonly browser: boolean + readonly skiller: boolean + readonly taskManager: boolean + readonly terminal: boolean +} + +const expectOptionalHandler = (handler: (() => void) | undefined, enabled: boolean): void => { + if (enabled) { + expect(typeof handler).toBe("function") + return + } + expect(handler).toBeUndefined() +} + +const expectProjectHandlers = (handlers: ProjectHandlers, expected: ExpectedProjectHandlers): void => { + expectOptionalHandler(handlers.onApplyProject, expected.apply) + expectOptionalHandler(handlers.onOpenBrowser, expected.browser) + expectOptionalHandler(handlers.onOpenSkiller, expected.skiller) + expectOptionalHandler(handlers.onOpenTaskManager, expected.taskManager) + expectOptionalHandler(handlers.onOpenTerminal, expected.terminal) +} + describe("useProjectActionHandlers", () => { + let openedWindow: BrowserOpenMockWindow = makeBrowserOpenMockWindow() + + beforeEach(() => { + vi.clearAllMocks() + openedWindow = makeBrowserOpenMockWindow() + stubBrowserOpen(openedWindow) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + it("builds new terminal popups as project SSH links with a terminal selector", () => { expect( newProjectTerminalUrl("https://docker-git.local", "octocat/hello-world", "session-1") ).toBe("https://docker-git.local/ssh/octocat/hello-world?t=session-1") }) - it("returns all four project action handlers when project context is present", () => { - const handlers = buildHandlers() - expect(typeof handlers.onApplyProject).toBe("function") - expect(typeof handlers.onOpenBrowser).toBe("function") - expect(typeof handlers.onOpenTaskManager).toBe("function") - expect(typeof handlers.onOpenTerminal).toBe("function") + it("returns all project action handlers when project context is present", () => { + expectProjectHandlers(buildHandlers(), { + apply: true, + browser: true, + skiller: true, + taskManager: true, + terminal: true + }) }) it("hides all handlers when projectId is missing", () => { - const handlers = buildHandlers({ projectId: undefined }) - expect(handlers.onApplyProject).toBeUndefined() - expect(handlers.onOpenBrowser).toBeUndefined() - expect(handlers.onOpenTaskManager).toBeUndefined() - expect(handlers.onOpenTerminal).toBeUndefined() + expectProjectHandlers(buildHandlers({ projectId: undefined }), { + apply: false, + browser: false, + skiller: false, + taskManager: false, + terminal: false + }) + }) + + it("hides project-key dependent handlers when projectKey is missing", () => { + expectProjectHandlers(buildHandlers({ projectKey: undefined }), { + apply: true, + browser: true, + skiller: false, + taskManager: true, + terminal: false + }) }) - it("hides only onOpenTerminal when projectKey is missing", () => { - const handlers = buildHandlers({ projectKey: undefined }) - expect(typeof handlers.onApplyProject).toBe("function") - expect(typeof handlers.onOpenBrowser).toBe("function") - expect(typeof handlers.onOpenTaskManager).toBe("function") - expect(handlers.onOpenTerminal).toBeUndefined() + it("hides only onOpenSkiller when terminalSessionId is missing", () => { + expectProjectHandlers(buildHandlers({ terminalSessionId: undefined }), { + apply: true, + browser: true, + skiller: false, + taskManager: true, + terminal: true + }) }) it("wires onOpenTaskManager to the supplied request callback", () => { @@ -62,4 +150,24 @@ describe("useProjectActionHandlers", () => { handlers.onOpenTaskManager?.() expect(opened).toBe(1) }) + + it.effect("opens Skiller for the current terminal session", () => + Effect.gen(function*(_) { + const setMessage = vi.fn() + terminalApiMocks.openSkiller.mockImplementation(() => Effect.succeed(skillerLaunch())) + const handlers = buildHandlers({ setMessage }) + + expect(typeof handlers.onOpenSkiller).toBe("function") + handlers.onOpenSkiller?.() + + expect(openedWindow.opener).toBeNull() + expect(setMessage).toHaveBeenCalledWith("Opening Skiller...") + yield* _(waitForAssertion(() => { + expect(terminalApiMocks.openSkiller).toHaveBeenCalledWith("octocat/hello-world", "session-1") + expect(openedWindow.location.href).toBe("/api/ssh/session/session-1/skiller/app/") + expect(setMessage).toHaveBeenCalledWith( + "Skiller launch started (pid 1234). Log: /home/dev/.docker-git/logs/skiller.log. Container FS: dg-project:/home/dev/app. Opened /api/ssh/session/session-1/skiller/app/." + ) + })) + })) }) diff --git a/packages/app/tests/docker-git/browser-open-fixture.ts b/packages/app/tests/docker-git/browser-open-fixture.ts new file mode 100644 index 00000000..084f4296 --- /dev/null +++ b/packages/app/tests/docker-git/browser-open-fixture.ts @@ -0,0 +1,25 @@ +import { vi } from "vitest" + +export type BrowserOpenMockWindow = { + readonly close: ReturnType + readonly focus: ReturnType + readonly location: { + href: string + } + opener: object | null +} + +export const makeBrowserOpenMockWindow = (): BrowserOpenMockWindow => ({ + close: vi.fn(), + focus: vi.fn(), + location: { + href: "" + }, + opener: {} +}) + +export const stubBrowserOpen = (openedWindow: BrowserOpenMockWindow): ReturnType => { + const open = vi.fn(() => openedWindow) + vi.stubGlobal("open", open) + return open +}