From 21828747782de2d8ca45458f54dce22be0e954c0 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 7 Sep 2026 21:11:00 +0000 Subject: [PATCH] feat(workspaces): IPC API and extension-side data layer for the panel Adds the typed IPC contract for the experimental Workspaces panel and the extension-side store that owns its data, porting the tree views' behaviors to push through IPC. No UI yet: the webview prints the pushed state so the data flow can be verified. `packages/shared/src/workspaces` defines the contract. One notification, `stateChanged`, carries the whole `WorkspacesState` whenever any of it changes; `ready`, `openWorkspace`, `viewInDashboard`, `refresh`, `setFilter` and `watchAgents` come back. The state is flat: capabilities, filter, workspaces, a `status` union (loading, ready, or failed with the error) and per-agent metadata as a union (pending, reported, or failed). Payloads carry decisions rather than facts to derive, so the webview holds no data and applies no policy: it asks for the state with `ready` and renders what arrives. `ready` also resets the watched agents, since a webview that just loaded shows nothing expanded. `WorkspaceStore` owns the panel's state: the active filter and its list, the filters the session may select (minus those the deployment rejected with HTTP 400, until a visible refresh re-validates them), and the agents the webview is showing. It pushes the whole state on every change and skips the push when nothing changed, so quiet polls stay off the wire. A hidden store keeps its list and its sockets; a revealed one lists again without a loading flicker. Callers raise `loading` (a filter switch, a refresh, a session change) and the list lowers it, so a poll never touches it. Split by concern: - `src/util/poller.ts`: runs one task at a time with a cancellation token per run, so a superseded fetch drops its result. The task answers with a delay in ms, `retry` (backing off to a cap) or `idle`. - `src/workspace/agentMetadataTracker.ts`: the watched set and its sockets, which linger briefly after release so toggling a row reuses them. - `src/workspace/filters.ts`: each filter's query, role requirement, poll interval and presentation, shared with the tree views, which now take a `WorkspaceFilter` instead of the `WorkspaceQuery` enum. `isQueryRejected` names the HTTP 400 both clients handle. - `src/webviews/dispatch.ts`: `dispatchWebviewMessage`, the request and command routing the Tasks, Workspaces and result panels use, with one `DispatchOptions` shape. `WorkspacesPanelProvider` takes the store and `Commands`, follows the view's visibility through one `syncVisibility`, and leaves theme changes to the webview. `Commands` gains `openWorkspaceFromSidebar` and `openWorkspaceInDashboard`, which the tree's sidebar commands share. `isOwner(user)` moved to `api-helper.ts` for `deploymentManager` and the filters. The mock `WebviewView` lives in `testHelpers.createMockWebviewView`, shared with the Tasks panel test. --- packages/mocks/src/workspaces.ts | 51 ++- packages/shared/src/index.ts | 3 +- packages/shared/src/workspaces/api.ts | 32 +- packages/shared/src/workspaces/types.ts | 70 ++++ packages/workspaces/src/App.tsx | 10 +- .../workspaces/src/hooks/useWorkspaces.ts | 24 ++ src/api/agentMetadataHelper.ts | 23 +- src/api/api-helper.ts | 6 + src/commands.ts | 105 +++--- src/deployment/deploymentManager.ts | 4 +- src/deployment/sessionStore.ts | 19 +- src/extension.ts | 26 +- src/util/poller.ts | 95 +++++ src/webviews/dispatch.ts | 33 +- src/webviews/resultPanel.ts | 24 +- src/webviews/tasks/tasksPanelProvider.ts | 30 +- src/webviews/workspaces/panelProvider.ts | 166 +++++++++ src/webviews/workspaces/workspaceStore.ts | 245 +++++++++++++ .../workspaces/workspacesPanelProvider.ts | 102 ------ src/workspace/agentMetadataTracker.ts | 179 ++++++++++ src/workspace/filters.ts | 69 ++++ src/workspace/workspacesProvider.ts | 58 +--- test/mocks/testHelpers.ts | 159 +++++++-- test/mocks/vscode.runtime.ts | 23 ++ test/tsconfig.json | 3 +- test/unit/api/api-helper.test.ts | 21 ++ test/unit/util/poller.test.ts | 123 +++++++ test/unit/webviews/dispatch.test.ts | 6 +- .../webviews/tasks/tasksPanelProvider.test.ts | 38 +- .../webviews/workspaces/panelProvider.test.ts | 295 ++++++++++++++++ .../workspaces/workspaceStore.test.ts | 325 ++++++++++++++++++ .../workspace/agentMetadataTracker.test.ts | 227 ++++++++++++ test/unit/workspace/filters.test.ts | 67 ++++ .../unit/workspace/workspacesProvider.test.ts | 69 ++-- .../webview/workspaces/useWorkspaces.test.tsx | 67 ++++ vitest.config.mts | 4 + 36 files changed, 2446 insertions(+), 355 deletions(-) create mode 100644 packages/shared/src/workspaces/types.ts create mode 100644 packages/workspaces/src/hooks/useWorkspaces.ts create mode 100644 src/util/poller.ts create mode 100644 src/webviews/workspaces/panelProvider.ts create mode 100644 src/webviews/workspaces/workspaceStore.ts delete mode 100644 src/webviews/workspaces/workspacesPanelProvider.ts create mode 100644 src/workspace/agentMetadataTracker.ts create mode 100644 src/workspace/filters.ts create mode 100644 test/unit/api/api-helper.test.ts create mode 100644 test/unit/util/poller.test.ts create mode 100644 test/unit/webviews/workspaces/panelProvider.test.ts create mode 100644 test/unit/webviews/workspaces/workspaceStore.test.ts create mode 100644 test/unit/workspace/agentMetadataTracker.test.ts create mode 100644 test/unit/workspace/filters.test.ts create mode 100644 test/webview/workspaces/useWorkspaces.test.tsx diff --git a/packages/mocks/src/workspaces.ts b/packages/mocks/src/workspaces.ts index 13378b1968..77b3458f1b 100644 --- a/packages/mocks/src/workspaces.ts +++ b/packages/mocks/src/workspaces.ts @@ -2,9 +2,11 @@ * Test factories for Coder SDK workspace types. */ +import type { AgentMetadataState } from "@repo/shared"; import type { Workspace, WorkspaceAgent, + WorkspaceAgentMetadata, WorkspaceBuild, WorkspaceResource, } from "coder/site/src/api/typesGenerated"; @@ -51,13 +53,21 @@ const defaultBuild: WorkspaceBuild = { template_version_preset_id: null, }; -/** Create a Workspace with sensible defaults for a running task workspace. */ +/** + * Create a Workspace with sensible defaults for a running task workspace. + * `agents` puts them on a single resource, the common shape in tests. + */ export function workspace( overrides: Omit, "latest_build"> & { latest_build?: Partial; + agents?: WorkspaceAgent[]; } = {}, ): Workspace { - const { latest_build: buildOverrides, ...rest } = overrides; + const { latest_build: buildOverrides, agents, ...rest } = overrides; + const build = { ...defaultBuild, ...buildOverrides }; + if (agents) { + build.resources = [resource({ agents })]; + } return { id: "workspace-1", created_at: "2024-01-01T00:00:00Z", @@ -75,7 +85,7 @@ export function workspace( template_active_version_id: "version-1", template_require_active_version: false, template_use_classic_parameter_flow: false, - latest_build: { ...defaultBuild, ...buildOverrides }, + latest_build: build, latest_app_status: null, outdated: false, name: "test-workspace", @@ -126,6 +136,41 @@ export function agent(overrides: Partial = {}): WorkspaceAgent { }; } +/** Create a WorkspaceAgentMetadata report with sensible defaults. */ +export function agentMetadata( + overrides: { + result?: Partial; + description?: Partial; + } = {}, +): WorkspaceAgentMetadata { + return { + result: { + collected_at: "2024-01-01T00:00:00Z", + age: 0, + value: "42", + error: "", + ...overrides.result, + }, + description: { + display_name: "CPU", + key: "cpu", + script: "cpu.sh", + interval: 5, + timeout: 1, + ...overrides.description, + }, + }; +} + +/** An agent whose socket is open, but which has not reported yet. */ +export const PENDING_METADATA: AgentMetadataState = { kind: "pending" }; + +/** An agent that reported `agentMetadata()`. */ +export const REPORTED_METADATA: AgentMetadataState = { + kind: "reported", + metadata: [agentMetadata()], +}; + /** Create a WorkspaceResource with sensible defaults. */ export function resource( overrides: Partial = {}, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4f9b15143b..684e49e72e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -30,5 +30,6 @@ export type { NetcheckSeverity, } from "./netcheck/types"; -// Workspaces API +// Workspaces types and API +export * from "./workspaces/types"; export { WorkspacesApi } from "./workspaces/api"; diff --git a/packages/shared/src/workspaces/api.ts b/packages/shared/src/workspaces/api.ts index 508b2e5b9e..2f0b6a20f5 100644 --- a/packages/shared/src/workspaces/api.ts +++ b/packages/shared/src/workspaces/api.ts @@ -1 +1,31 @@ -export const WorkspacesApi = {} as const; +/** + * Workspaces API - Type-safe message definitions for the Workspaces webview. + * + * The extension owns the data and pushes it; the webview renders what it is + * given and sends back the actions the user takes. + */ + +import { defineCommand, defineNotification } from "../ipc/protocol"; + +import type { + OpenWorkspaceParams, + SetFilterParams, + ViewInDashboardParams, + WatchAgentsParams, + WorkspacesState, +} from "./types"; + +export const WorkspacesApi = { + // Notifications + /** The whole state, whenever any of it changes */ + stateChanged: defineNotification("stateChanged"), + // Commands + /** Webview signals its subscription is live and asks for the state */ + ready: defineCommand("ready"), + openWorkspace: defineCommand("openWorkspace"), + viewInDashboard: defineCommand("viewInDashboard"), + refresh: defineCommand("refresh"), + setFilter: defineCommand("setFilter"), + /** Watch metadata for these agents only, so idle rows cost nothing */ + watchAgents: defineCommand("watchAgents"), +} as const; diff --git a/packages/shared/src/workspaces/types.ts b/packages/shared/src/workspaces/types.ts new file mode 100644 index 0000000000..41eb4539d7 --- /dev/null +++ b/packages/shared/src/workspaces/types.ts @@ -0,0 +1,70 @@ +import type { + Workspace, + WorkspaceAgent, + WorkspaceAgentMetadata, +} from "coder/site/src/api/typesGenerated"; + +// Re-export SDK types for convenience +export type { Workspace, WorkspaceAgent, WorkspaceAgentMetadata }; + +export type WorkspaceFilter = "mine" | "shared" | "all"; + +/** A workspace page in the dashboard, opened in the browser. */ +export type DashboardPage = "workspace" | "settings"; + +/** What the panel may offer for the current session. */ +export interface WorkspacesCapabilities { + readonly authenticated: boolean; + /** Filters the user may select, in display order. */ + readonly filters: readonly WorkspaceFilter[]; +} + +/** + * What the list is doing. `loading` is set only for a list the user waits on: + * the first one for a filter, or a refresh. Polls never set it. + */ +export type WorkspaceListStatus = + | { readonly kind: "loading" } + | { readonly kind: "ready" } + | { readonly kind: "failed"; readonly error: string }; + +/** What one agent reports. A failure replaces its metadata in the UI. */ +export type AgentMetadataState = + | { readonly kind: "pending" } + | { + readonly kind: "reported"; + readonly metadata: readonly WorkspaceAgentMetadata[]; + } + | { readonly kind: "failed"; readonly error: string }; + +/** Keyed by agent id. */ +export type AgentMetadataMap = Readonly>; + +/** Everything the panel renders. Pushed whole whenever any of it changes. */ +export interface WorkspacesState { + readonly capabilities: WorkspacesCapabilities; + readonly filter: WorkspaceFilter; + readonly workspaces: readonly Workspace[]; + readonly status: WorkspaceListStatus; + readonly metadata: AgentMetadataMap; +} + +export interface OpenWorkspaceParams { + readonly workspaceId: string; + /** Which agent to connect to. Picked interactively when omitted. */ + readonly agentId?: string; +} + +export interface ViewInDashboardParams { + readonly workspaceId: string; + readonly page: DashboardPage; +} + +export interface SetFilterParams { + readonly filter: WorkspaceFilter; +} + +export interface WatchAgentsParams { + /** The agents whose metadata the webview is showing. */ + readonly agentIds: readonly string[]; +} diff --git a/packages/workspaces/src/App.tsx b/packages/workspaces/src/App.tsx index abed211177..fe16f87c2d 100644 --- a/packages/workspaces/src/App.tsx +++ b/packages/workspaces/src/App.tsx @@ -1,3 +1,11 @@ +import { useWorkspaces } from "./hooks/useWorkspaces"; + +/** Placeholder: renders the pushed state until the panel UI lands. */ export default function App() { - return
TODO
; + const { state } = useWorkspaces(); + + if (!state) { + return

Loading workspaces...

; + } + return
{JSON.stringify(state, null, 2)}
; } diff --git a/packages/workspaces/src/hooks/useWorkspaces.ts b/packages/workspaces/src/hooks/useWorkspaces.ts new file mode 100644 index 0000000000..86efb089ef --- /dev/null +++ b/packages/workspaces/src/hooks/useWorkspaces.ts @@ -0,0 +1,24 @@ +import { + buildApiHook, + WorkspacesApi, + type WorkspacesState, +} from "@repo/shared"; +import { useIpc } from "@repo/webview-shared/react"; +import { useEffect, useState } from "react"; + +/** + * The state the extension pushes, and the commands to send back. The state is + * undefined until the extension answers `ready`. + */ +export function useWorkspaces() { + const api = buildApiHook(WorkspacesApi, useIpc()); + const [state, setState] = useState(); + + useEffect(() => { + const unsubscribe = api.onStateChanged(setState); + api.ready(); + return unsubscribe; + }, []); + + return { state, api }; +} diff --git a/src/api/agentMetadataHelper.ts b/src/api/agentMetadataHelper.ts index dfdf7d41f2..b296d327ae 100644 --- a/src/api/agentMetadataHelper.ts +++ b/src/api/agentMetadataHelper.ts @@ -6,13 +6,22 @@ import { AgentMetadataEventSchemaArray, errToStr, } from "./api-helper"; -import { type CoderApi } from "./coderApi"; + +import type { UnidirectionalStream } from "../websocket/eventStreamConnection"; + +export interface AgentMetadataClient { + watchAgentMetadata( + agentId: string, + ): Promise>; +} export interface AgentMetadataWatcher { - onChange: vscode.EventEmitter["event"]; - dispose: () => void; + readonly onChange: vscode.EventEmitter["event"]; + readonly dispose: () => void; metadata?: AgentMetadataEvent[]; error?: unknown; + /** True once the socket closed on its own, so it reports nothing more. */ + closed: boolean; } /** @@ -21,7 +30,7 @@ export interface AgentMetadataWatcher { */ export async function createAgentMetadataWatcher( agentId: WorkspaceAgent["id"], - client: CoderApi, + client: AgentMetadataClient, ): Promise { const socket = await client.watchAgentMetadata(agentId); @@ -29,10 +38,13 @@ export async function createAgentMetadataWatcher( const onChange = new vscode.EventEmitter(); const watcher: AgentMetadataWatcher = { onChange: onChange.event, + closed: false, dispose: () => { if (!disposed) { - socket.close(); disposed = true; + // Listeners go first, so closing the socket reports nothing more. + onChange.dispose(); + socket.close(); } }, }; @@ -70,6 +82,7 @@ export async function createAgentMetadataWatcher( socket.addEventListener("error", handleError); socket.addEventListener("close", (event) => { + watcher.closed = true; if (event.code !== 1000) { handleError( new Error( diff --git a/src/api/api-helper.ts b/src/api/api-helper.ts index e1f504302c..6a2d7c6b7c 100644 --- a/src/api/api-helper.ts +++ b/src/api/api-helper.ts @@ -1,5 +1,6 @@ import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors"; import { + type User, type Workspace, type WorkspaceAgent, type WorkspaceResource, @@ -27,6 +28,11 @@ export function errToStr(error: unknown, def = "No error message provided") { return def; } +/** True when the user holds the deployment-wide owner role. */ +export function isOwner(user: User | undefined): boolean { + return user?.roles.some((role) => role.name === "owner") ?? false; +} + /** * Create workspace owner/name identifier */ diff --git a/src/commands.ts b/src/commands.ts index 8f9268b6d5..738897d545 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -75,6 +75,8 @@ import type { WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; +import type { DashboardPage } from "@repo/shared"; + import type { CoderApi } from "./api/coderApi"; import type { CliManager } from "./core/cliManager"; import type { ServiceContainer } from "./core/container"; @@ -900,8 +902,7 @@ export class Commands { */ public async navigateToWorkspace(item?: OpenableTreeItem) { if (item) { - const workspaceId = createWorkspaceIdentifier(item.workspace); - await openInBrowser(this.requireExtensionBaseUrl(), `/@${workspaceId}`); + await this.openWorkspaceInDashboard(item.workspace, "workspace"); } else if (this.workspace && this.remoteWorkspaceClient) { await openInBrowser( this.requireRemoteBaseUrl(), @@ -922,11 +923,7 @@ export class Commands { */ public async navigateToWorkspaceSettings(item?: OpenableTreeItem) { if (item) { - const workspaceId = createWorkspaceIdentifier(item.workspace); - await openInBrowser( - this.requireExtensionBaseUrl(), - `/@${workspaceId}/settings`, - ); + await this.openWorkspaceInDashboard(item.workspace, "settings"); } else if (this.workspace && this.remoteWorkspaceClient) { await openInBrowser( this.requireRemoteBaseUrl(), @@ -937,6 +934,21 @@ export class Commands { } } + /** + * Open a page of a workspace in the Coder dashboard. The workspace must + * belong to the currently logged-in deployment. + */ + public async openWorkspaceInDashboard( + workspace: Workspace, + page: DashboardPage, + ): Promise { + const workspaceId = createWorkspaceIdentifier(workspace); + await openInBrowser( + this.requireExtensionBaseUrl(), + `/@${workspaceId}${page === "settings" ? "/settings" : ""}`, + ); + } + /** * Open a workspace or agent that is showing in the sidebar. * @@ -946,23 +958,12 @@ export class Commands { * Throw if not logged into a deployment. */ public async openFromSidebar(item: OpenableTreeItem): Promise { - if (item) { - const baseUrl = this.requireExtensionBaseUrl(); - if (item instanceof AgentTreeItem) { - await this.workspaceOpenTelemetry.traceOpen( - "sidebar_agent", - { workspace: item.workspace, agent: item.agent }, - (telemetry) => this.runOpenAgentItem(baseUrl, item, telemetry), - ); - } else if (item instanceof WorkspaceTreeItem) { - await this.workspaceOpenTelemetry.traceOpen( - "sidebar_workspace", - { workspace: item.workspace }, - (telemetry) => this.runOpenWorkspaceItem(baseUrl, item, telemetry), - ); - } else { - throw new TypeError("Unable to open unknown sidebar item"); - } + if (item instanceof AgentTreeItem) { + await this.openWorkspaceFromSidebar(item.workspace, item.agent); + } else if (item instanceof WorkspaceTreeItem) { + await this.openWorkspaceFromSidebar(item.workspace); + } else if (item) { + throw new TypeError("Unable to open unknown sidebar item"); } else { // If there is no tree item, then the user manually ran this command. // Default to the regular open instead. @@ -970,40 +971,58 @@ export class Commands { } } + /** + * Open a workspace, or one of its agents, that the sidebar lists. + * + * Throws if not logged into a deployment. + */ + public async openWorkspaceFromSidebar( + workspace: Workspace, + agent?: WorkspaceAgent, + ): Promise { + const baseUrl = this.requireExtensionBaseUrl(); + if (agent) { + await this.workspaceOpenTelemetry.traceOpen( + "sidebar_agent", + { workspace, agent }, + (telemetry) => + this.runOpenAgentItem(baseUrl, workspace, agent, telemetry), + ); + } else { + await this.workspaceOpenTelemetry.traceOpen( + "sidebar_workspace", + { workspace }, + (telemetry) => this.runOpenWorkspaceItem(baseUrl, workspace, telemetry), + ); + } + } + private async runOpenAgentItem( baseUrl: string, - item: AgentTreeItem, + workspace: Workspace, + agent: WorkspaceAgent, telemetry: WorkspaceOpenTrace, ): Promise { - const result = await this.openWorkspace( - baseUrl, - item.workspace, - item.agent, - { - openRecent: true, - }, - ); - return recordOpenResult( - telemetry, - { workspace: item.workspace, agent: item.agent }, - result, - ); + const result = await this.openWorkspace(baseUrl, workspace, agent, { + openRecent: true, + }); + return recordOpenResult(telemetry, { workspace, agent }, result); } private async runOpenWorkspaceItem( baseUrl: string, - item: WorkspaceTreeItem, + workspace: Workspace, telemetry: WorkspaceOpenTrace, ): Promise { - const agents = await this.extractAgentsWithFallback(item.workspace); + const agents = await this.extractAgentsWithFallback(workspace); const agent = await maybeAskAgent(agents); if (!agent) { - telemetry.abort("agent_picker", { workspace: item.workspace }); + telemetry.abort("agent_picker", { workspace }); return false; } - const selection = { workspace: item.workspace, agent }; + const selection = { workspace, agent }; telemetry.select(selection); - const result = await this.openWorkspace(baseUrl, item.workspace, agent, { + const result = await this.openWorkspace(baseUrl, workspace, agent, { openRecent: true, }); return recordOpenResult(telemetry, selection, result); diff --git a/src/deployment/deploymentManager.ts b/src/deployment/deploymentManager.ts index 7c0c47e3e2..36e6fac94d 100644 --- a/src/deployment/deploymentManager.ts +++ b/src/deployment/deploymentManager.ts @@ -1,3 +1,4 @@ +import { isOwner } from "../api/api-helper"; import { CoderApi } from "../api/coderApi"; import { CONFIG_CHANGE_DEBOUNCE_MS, @@ -420,8 +421,7 @@ export class DeploymentManager implements vscode.Disposable { */ private updateAuthContexts(user: User | undefined): void { this.contextManager.set("coder.authenticated", Boolean(user)); - const isOwner = user?.roles.some((r) => r.name === "owner") ?? false; - this.contextManager.set("coder.isOwner", isOwner); + this.contextManager.set("coder.isOwner", isOwner(user)); } /** diff --git a/src/deployment/sessionStore.ts b/src/deployment/sessionStore.ts index 285be815d4..5918b34de8 100644 --- a/src/deployment/sessionStore.ts +++ b/src/deployment/sessionStore.ts @@ -4,6 +4,17 @@ import type { User } from "coder/site/src/api/typesGenerated"; import type { Deployment } from "./types"; +export interface SignedOutSession { + readonly kind: "signedOut"; + readonly deployment: Deployment | null; +} + +export interface SignedInSession { + readonly kind: "signedIn"; + readonly deployment: Deployment; + readonly user: User; +} + /** * The deployment session: signed out (optionally keeping the last deployment * for re-login) or signed in with an authenticated user. @@ -11,13 +22,7 @@ import type { Deployment } from "./types"; * Every transition makes a new object, so callers can spot a change by * comparing identity against an earlier value. */ -export type SessionData = - | { readonly kind: "signedOut"; readonly deployment: Deployment | null } - | { - readonly kind: "signedIn"; - readonly deployment: Deployment; - readonly user: User; - }; +export type SessionData = SignedOutSession | SignedInSession; /** * Read-only session access: the current data plus change notifications. diff --git a/src/extension.ts b/src/extension.ts index e5733b0bb2..8506c097d5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -27,11 +27,9 @@ import { getRemoteSshExtension } from "./remote/sshExtension"; import { registerUriHandler } from "./uri/uriHandler"; import { initVscodeProposed } from "./vscodeProposed"; import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider"; -import { WorkspacesPanelProvider } from "./webviews/workspaces/workspacesPanelProvider"; -import { - WorkspaceProvider, - WorkspaceQuery, -} from "./workspace/workspacesProvider"; +import { WorkspacesPanelProvider } from "./webviews/workspaces/panelProvider"; +import { WorkspaceStore } from "./webviews/workspaces/workspaceStore"; +import { WorkspaceProvider } from "./workspace/workspacesProvider"; const MY_WORKSPACES_TREE_ID = "myWorkspaces"; const SHARED_WORKSPACES_TREE_ID = "sharedWorkspaces"; @@ -174,16 +172,15 @@ async function doActivate( ctx.subscriptions.push(announcementManager); const myWorkspacesProvider = new WorkspaceProvider( - WorkspaceQuery.Mine, + "mine", client, output, deploymentManager.session, - { refreshIntervalMs: 5_000 }, ); ctx.subscriptions.push(myWorkspacesProvider); const allWorkspacesProvider = new WorkspaceProvider( - WorkspaceQuery.All, + "all", client, output, deploymentManager.session, @@ -191,7 +188,7 @@ async function doActivate( ctx.subscriptions.push(allWorkspacesProvider); const sharedWorkspacesProvider = new WorkspaceProvider( - WorkspaceQuery.Shared, + "shared", client, output, deploymentManager.session, @@ -302,21 +299,26 @@ async function doActivate( contextManager.set("coder.workspacesPanelEnabled", workspacesPanelEnabled); if (workspacesPanelEnabled) { + const workspaceStore = new WorkspaceStore( + client, + output, + deploymentManager.session, + ); const workspacesPanelProvider = new WorkspacesPanelProvider( ctx.extensionUri, + workspaceStore, + commands, output, ); ctx.subscriptions.push( + workspaceStore, workspacesPanelProvider, vscode.window.registerWebviewViewProvider( WorkspacesPanelProvider.viewType, workspacesPanelProvider, { webviewOptions: { retainContextWhenHidden: true } }, ), - secretsManager.onDidChangeCurrentDeployment(() => - workspacesPanelProvider.refresh(), - ), ); } diff --git a/src/util/poller.ts b/src/util/poller.ts new file mode 100644 index 0000000000..2875b17e7f --- /dev/null +++ b/src/util/poller.ts @@ -0,0 +1,95 @@ +import * as vscode from "vscode"; + +import type { Logger } from "../logging/logger"; + +/** What a task asks for once it is done. */ +export type NextRun = { readonly delayMs: number } | "retry" | "idle"; + +export interface RetryOptions { + /** Delay after the first failure in a row. Each one after doubles it. */ + readonly initialDelayMs: number; + readonly maxDelayMs: number; +} + +/** + * Runs one task at a time, on the schedule the task asks for. Starting a run + * cancels the token of the run in flight, so a superseded task can drop what + * it fetched instead of publishing it. + */ +export class Poller implements vscode.Disposable { + private source: vscode.CancellationTokenSource | undefined; + private timer: NodeJS.Timeout | undefined; + private retries = 0; + private settledPromise = Promise.resolve(); + + constructor( + private readonly task: ( + token: vscode.CancellationToken, + ) => Promise, + private readonly retry: RetryOptions, + private readonly logger: Logger, + ) {} + + /** + * The run in flight, or the last one. Never rejects: a task that throws is + * logged and retried. + */ + public get settled(): Promise { + return this.settledPromise; + } + + /** Cancel the run in flight and run the task now, starting the backoff over. */ + public run(): Promise { + this.retries = 0; + return this.start(); + } + + public dispose(): void { + this.cancel(); + } + + private start(): Promise { + this.cancel(); + const source = new vscode.CancellationTokenSource(); + this.source = source; + this.settledPromise = this.attempt(source.token).finally(() => { + if (this.source === source) { + this.source = undefined; + } + source.dispose(); + }); + return this.settledPromise; + } + + private async attempt(token: vscode.CancellationToken): Promise { + let next: NextRun; + try { + next = await this.task(token); + } catch (error) { + this.logger.error("Unexpected failure in a polled task", error); + next = "retry"; + } + // A superseded or disposed run schedules nothing. + if (token.isCancellationRequested) { + return; + } + this.retries = next === "retry" ? this.retries + 1 : 0; + if (next === "idle") { + return; + } + const delayMs = + next === "retry" + ? Math.min( + this.retry.initialDelayMs * 2 ** (this.retries - 1), + this.retry.maxDelayMs, + ) + : next.delayMs; + this.timer = setTimeout(() => void this.start(), delayMs); + } + + private cancel(): void { + clearTimeout(this.timer); + this.source?.cancel(); + this.source = undefined; + } +} diff --git a/src/webviews/dispatch.ts b/src/webviews/dispatch.ts index 3c17d27271..d097314155 100644 --- a/src/webviews/dispatch.ts +++ b/src/webviews/dispatch.ts @@ -6,9 +6,9 @@ import { type Logger } from "../logging/logger"; import type { IpcRequest, IpcResponse, NotificationDef } from "@repo/shared"; export interface DispatchOptions { - logger: Logger; - /** Returning true shows the handler's error via `showErrorMessage`. */ - showErrorToUser?: (method: string) => boolean; + readonly logger: Logger; + /** Methods whose failures warrant a dialog; others are logged only. */ + readonly userActions?: ReadonlySet; } /** Push a typed notification to a webview. No-op when `webview` is undefined. */ @@ -73,6 +73,31 @@ export async function dispatchRequest( } } +export interface WebviewHandlers { + readonly requests: Readonly< + Record Promise> + >; + readonly commands: Readonly< + Record void | Promise> + >; +} + +/** Route a message from a webview to its request or command handler. */ +export async function dispatchWebviewMessage( + message: unknown, + handlers: WebviewHandlers, + webview: vscode.Webview | undefined, + options: DispatchOptions, +): Promise { + if (isIpcRequest(message)) { + await dispatchRequest(message, handlers.requests, webview, options); + } else if (isIpcCommand(message)) { + await dispatchCommand(message, handlers.commands, options); + } else { + options.logger.warn("Unexpected webview message", message); + } +} + /** Fire `handler` on `event` only while `panel.visible` is true. */ export function onWhileVisible( panel: { readonly visible: boolean }, @@ -119,7 +144,7 @@ function handleDispatchError( ): void { const message = toError(err).message; options.logger.warn(`${kind} ${method} failed`, err); - if (options.showErrorToUser?.(method)) { + if (options.userActions?.has(method)) { vscode.window.showErrorMessage(message); } } diff --git a/src/webviews/resultPanel.ts b/src/webviews/resultPanel.ts index 79981d203c..42d1fcb7bf 100644 --- a/src/webviews/resultPanel.ts +++ b/src/webviews/resultPanel.ts @@ -1,12 +1,6 @@ import * as vscode from "vscode"; -import { - dispatchCommand, - dispatchRequest, - isIpcCommand, - isIpcRequest, - onWhileVisible, -} from "./dispatch"; +import { dispatchWebviewMessage, onWhileVisible } from "./dispatch"; import { getWebviewHtml } from "./html"; import { openJsonBeside } from "./openJson"; @@ -90,16 +84,12 @@ export function showResultPanel(options: ResultPanelOptions): void { onWhileVisible(panel, panel.onDidChangeViewState, sendData), onWhileVisible(panel, vscode.window.onDidChangeActiveColorTheme, sendData), panel.webview.onDidReceiveMessage((message: unknown) => { - if (isIpcRequest(message)) { - void dispatchRequest(message, requests, panel.webview, { logger }); - } else if (isIpcCommand(message)) { - void dispatchCommand(message, commands, { logger }); - } else { - logger.warn( - `Ignoring unrecognized ${webviewName} webview message`, - message, - ); - } + void dispatchWebviewMessage( + message, + { requests, commands }, + panel.webview, + { logger }, + ); }), ]; panel.onDidDispose(() => { diff --git a/src/webviews/tasks/tasksPanelProvider.ts b/src/webviews/tasks/tasksPanelProvider.ts index 9c465583dd..b2a01dea12 100644 --- a/src/webviews/tasks/tasksPanelProvider.ts +++ b/src/webviews/tasks/tasksPanelProvider.ts @@ -27,13 +27,7 @@ import { import { type Logger } from "../../logging/logger"; import { openInBrowser } from "../../util/uri"; import { vscodeProposed } from "../../vscodeProposed"; -import { - dispatchCommand, - dispatchRequest, - isIpcCommand, - isIpcRequest, - notifyWebview, -} from "../dispatch"; +import { dispatchWebviewMessage, notifyWebview } from "../dispatch"; import { getWebviewHtml } from "../html"; import type { @@ -163,20 +157,16 @@ export class TasksPanelProvider }); } - private async handleMessage(message: unknown): Promise { - const showErrorToUser = (method: string) => - TasksPanelProvider.USER_ACTION_METHODS.has(method); - if (isIpcRequest(message)) { - await dispatchRequest(message, this.requestHandlers, this.view?.webview, { - logger: this.logger, - showErrorToUser, - }); - } else if (isIpcCommand(message)) { - await dispatchCommand(message, this.commandHandlers, { + private handleMessage(message: unknown): Promise { + return dispatchWebviewMessage( + message, + { requests: this.requestHandlers, commands: this.commandHandlers }, + this.view?.webview, + { logger: this.logger, - showErrorToUser, - }); - } + userActions: TasksPanelProvider.USER_ACTION_METHODS, + }, + ); } private async handleGetTaskDetails(taskId: string): Promise { diff --git a/src/webviews/workspaces/panelProvider.ts b/src/webviews/workspaces/panelProvider.ts new file mode 100644 index 0000000000..f91a6b239c --- /dev/null +++ b/src/webviews/workspaces/panelProvider.ts @@ -0,0 +1,166 @@ +import * as vscode from "vscode"; + +import { + buildCommandHandlers, + WorkspacesApi, + type OpenWorkspaceParams, +} from "@repo/shared"; + +import { extractAgents } from "../../api/api-helper"; +import { + dispatchWebviewMessage, + notifyWebview, + type WebviewHandlers, +} from "../dispatch"; +import { getWebviewHtml } from "../html"; + +import type { Workspace } from "coder/site/src/api/typesGenerated"; + +import type { Commands } from "../../commands"; +import type { Logger } from "../../logging/logger"; + +import type { WorkspaceStore } from "./workspaceStore"; + +/** Methods whose failures warrant a dialog; others are logged only. */ +const USER_ACTION_METHODS: ReadonlySet = new Set([ + WorkspacesApi.openWorkspace.method, + WorkspacesApi.viewInDashboard.method, +]); + +/** + * Renders the workspaces of the current deployment in a webview. All state + * lives in the store: every change is pushed whole, and a webview that just + * loaded asks for it with `ready`. + */ +export class WorkspacesPanelProvider + implements vscode.WebviewViewProvider, vscode.Disposable +{ + public static readonly viewType = "coder.workspacesPanel"; + + private readonly handlers: WebviewHandlers = { + requests: {}, + commands: buildCommandHandlers(WorkspacesApi, { + ready: () => { + // A webview that just loaded has nothing expanded. + void this.store.watchAgents([]); + this.pushState(); + }, + refresh: () => this.store.refresh(), + setFilter: (p) => this.store.setFilter(p.filter), + watchAgents: (p) => this.store.watchAgents(p.agentIds), + openWorkspace: (p) => this.openWorkspace(p), + viewInDashboard: (p) => + this.commands.openWorkspaceInDashboard( + this.requireWorkspace(p.workspaceId), + p.page, + ), + }), + }; + + private view: vscode.WebviewView | undefined; + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly store: WorkspaceStore, + private readonly commands: Pick< + Commands, + "openWorkspaceFromSidebar" | "openWorkspaceInDashboard" + >, + private readonly logger: Logger, + ) {} + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + token: vscode.CancellationToken, + ): void { + if (token.isCancellationRequested) { + return; + } + // Drop the view being replaced first, so its disposal cannot reach this one. + this.detachView(); + this.view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath( + this.extensionUri, + "dist", + "webviews", + "workspaces", + ), + ], + }; + + this.disposables.push( + this.store.onDidChange((state) => this.pushState(state)), + webviewView.onDidChangeVisibility(() => this.syncVisibility()), + webviewView.webview.onDidReceiveMessage((message: unknown) => { + dispatchWebviewMessage(message, this.handlers, webviewView.webview, { + logger: this.logger, + userActions: USER_ACTION_METHODS, + }).catch((err: unknown) => { + this.logger.error("Unhandled error in message handler", err); + }); + }), + webviewView.onDidDispose(() => { + this.detachView(); + this.syncVisibility(); + }), + ); + + webviewView.webview.html = getWebviewHtml( + webviewView.webview, + this.extensionUri, + "workspaces", + "Coder Workspaces", + ); + + this.syncVisibility(); + } + + dispose(): void { + this.detachView(); + this.syncVisibility(); + } + + private syncVisibility(): void { + void this.store.setVisible(this.view?.visible ?? false); + } + + private pushState(state = this.store.state): void { + notifyWebview(this.view?.webview, WorkspacesApi.stateChanged, state); + } + + private async openWorkspace({ + workspaceId, + agentId, + }: OpenWorkspaceParams): Promise { + const workspace = this.requireWorkspace(workspaceId); + const agent = extractAgents(workspace.latest_build.resources).find( + (candidate) => candidate.id === agentId, + ); + if (agentId && !agent) { + throw new Error("Agent is no longer available"); + } + await this.commands.openWorkspaceFromSidebar(workspace, agent); + } + + private requireWorkspace(workspaceId: string): Workspace { + const workspace = this.store.findWorkspace(workspaceId); + if (!workspace) { + throw new Error("Workspace is no longer available"); + } + return workspace; + } + + private detachView(): void { + for (const disposable of this.disposables) { + disposable.dispose(); + } + this.disposables = []; + this.view = undefined; + } +} diff --git a/src/webviews/workspaces/workspaceStore.ts b/src/webviews/workspaces/workspaceStore.ts new file mode 100644 index 0000000000..f4a4f528ec --- /dev/null +++ b/src/webviews/workspaces/workspaceStore.ts @@ -0,0 +1,245 @@ +import { isDeepStrictEqual } from "node:util"; +import * as vscode from "vscode"; + +import { errToStr, extractAllAgents } from "../../api/api-helper"; +import { Poller, type NextRun, type RetryOptions } from "../../util/poller"; +import { AgentMetadataTracker } from "../../workspace/agentMetadataTracker"; +import { + availableFilters, + DEFAULT_WORKSPACE_FILTER, + isQueryRejected, + WORKSPACE_FILTERS, +} from "../../workspace/filters"; + +import type { + Workspace, + WorkspaceFilter, + WorkspaceListStatus, + WorkspacesState, +} from "@repo/shared"; + +import type { AgentMetadataClient } from "../../api/agentMetadataHelper"; +import type { CoderApi } from "../../api/coderApi"; +import type { SessionState } from "../../deployment/sessionStore"; +import type { Logger } from "../../logging/logger"; + +interface WorkspacesClient extends AgentMetadataClient { + getWorkspaces( + request: Parameters[0], + ): Promise<{ readonly workspaces: readonly Workspace[] }>; +} + +const RETRY: RetryOptions = { initialDelayMs: 5_000, maxDelayMs: 60_000 }; +const LOADING: WorkspaceListStatus = { kind: "loading" }; +const READY: WorkspaceListStatus = { kind: "ready" }; + +function listedAgentIds( + requested: readonly string[], + workspaces: readonly Workspace[], +): readonly string[] { + if (requested.length === 0) { + return []; + } + const listed = new Set(extractAllAgents(workspaces).map((agent) => agent.id)); + return requested.filter((agentId) => listed.has(agentId)); +} + +/** + * Owns the state the Workspaces panel renders: lists the active filter while + * visible, watches the agents the panel is showing, and reports every change. + */ +export class WorkspaceStore implements vscode.Disposable { + private readonly changeEmitter = new vscode.EventEmitter(); + private readonly agents: AgentMetadataTracker; + private readonly poller: Poller; + private readonly disposables: readonly vscode.Disposable[]; + /** Filters this deployment rejected, so they are no longer offered. */ + private readonly unsupportedFilters = new Set(); + + public readonly onDidChange = this.changeEmitter.event; + + private filter: WorkspaceFilter = DEFAULT_WORKSPACE_FILTER; + private workspaces: readonly Workspace[] = []; + private status = LOADING; + /** Agents the webview is showing, listed or not. */ + private requestedAgents: readonly string[] = []; + private lastEmitted: WorkspacesState | undefined; + private visible = false; + private disposed = false; + + constructor( + private readonly client: WorkspacesClient, + private readonly logger: Logger, + private readonly sessionState: SessionState, + ) { + this.agents = new AgentMetadataTracker(client); + this.poller = new Poller((token) => this.list(token), RETRY, logger); + this.disposables = [ + this.poller, + this.agents, + this.agents.onDidChange(() => this.publish()), + this.sessionState.onDidChange(() => this.handleSessionChange()), + ]; + } + + public get state(): WorkspacesState { + const session = this.sessionState.current; + return { + capabilities: { + authenticated: session.kind === "signedIn", + filters: availableFilters(session, this.unsupportedFilters), + }, + filter: this.filter, + workspaces: this.workspaces, + status: this.status, + metadata: this.agents.metadata, + }; + } + + /** The list in flight, or the last one. Never rejects. */ + public get settled(): Promise { + return this.poller.settled; + } + + public findWorkspace(workspaceId: string): Workspace | undefined { + return this.workspaces.find((workspace) => workspace.id === workspaceId); + } + + /** A hidden store keeps its list and sockets; a revealed one lists again. */ + public setVisible(visible: boolean): Promise { + if (this.disposed || this.visible === visible) { + return this.settled; + } + this.visible = visible; + return visible ? this.poller.run() : this.settled; + } + + public setFilter(filter: WorkspaceFilter): Promise { + if (this.disposed || filter === this.filter) { + return this.settled; + } + if (!this.filters.includes(filter)) { + this.logger.warn(`Ignoring unavailable workspaces filter: ${filter}`); + return this.settled; + } + this.filter = filter; + return this.reload(); + } + + /** Watch exactly these agents, as far as the list has them. */ + public watchAgents(agentIds: readonly string[]): Promise { + this.requestedAgents = agentIds; + return this.watchListedAgents(); + } + + /** List now, keeping the list on show and offering rejected filters again. */ + public refresh(): Promise { + // A hidden panel cannot re-validate a filter it offers again. + if (this.disposed || !this.visible) { + return this.settled; + } + this.unsupportedFilters.clear(); + return this.reload(this.workspaces); + } + + public dispose(): void { + this.disposed = true; + for (const disposable of this.disposables) { + disposable.dispose(); + } + this.changeEmitter.dispose(); + } + + private get filters(): readonly WorkspaceFilter[] { + return availableFilters(this.sessionState.current, this.unsupportedFilters); + } + + private async list(token: vscode.CancellationToken): Promise { + if (this.disposed) { + return "idle"; + } + const session = this.sessionState.current; + if (session.kind !== "signedIn") { + this.setList([]); + return "idle"; + } + if (!this.visible) { + return "idle"; + } + const { getQuery, pollIntervalMs } = WORKSPACE_FILTERS[this.filter]; + try { + const { workspaces } = await this.client.getWorkspaces({ + q: getQuery(session), + }); + // Cancelled by a filter switch, a refresh, a session change, or disposal. + if (token.isCancellationRequested) { + return "idle"; + } + this.setList(workspaces); + return this.visible && pollIntervalMs !== undefined + ? { delayMs: pollIntervalMs } + : "idle"; + } catch (error) { + if (token.isCancellationRequested) { + return "idle"; + } + // The default filter's query is as old as the API; never drop it. + if (this.filter !== DEFAULT_WORKSPACE_FILTER && isQueryRejected(error)) { + this.unsupportedFilters.add(this.filter); + this.filter = DEFAULT_WORKSPACE_FILTER; + this.setList([], LOADING); + return this.list(token); + } + this.logger.warn("Failed to fetch workspaces:", error); + this.setList([], { + kind: "failed", + error: errToStr(error, "Failed to fetch workspaces"), + }); + return "retry"; + } + } + + /** Show `workspaces` as loading, then list again. */ + private reload(workspaces: readonly Workspace[] = []): Promise { + this.setList(workspaces, LOADING); + return this.poller.run(); + } + + private setList(workspaces: readonly Workspace[], status = READY): void { + this.workspaces = workspaces; + this.status = status; + void this.watchListedAgents(); + this.publish(); + } + + private watchListedAgents(): Promise { + return this.agents + .watch(listedAgentIds(this.requestedAgents, this.workspaces)) + .catch((error: unknown) => { + // Metadata is supplementary: never report it as a failure to list. + this.logger.warn("Failed to watch agent metadata:", error); + }); + } + + private publish(): void { + if (this.disposed) { + return; + } + const state = this.state; + if (!isDeepStrictEqual(state, this.lastEmitted)) { + this.lastEmitted = state; + this.changeEmitter.fire(state); + } + } + + private handleSessionChange(): void { + this.unsupportedFilters.clear(); + this.requestedAgents = []; + if (!this.filters.includes(this.filter)) { + this.filter = DEFAULT_WORKSPACE_FILTER; + } + void this.reload(); + // Another session's sockets must not linger. + this.agents.clear(); + } +} diff --git a/src/webviews/workspaces/workspacesPanelProvider.ts b/src/webviews/workspaces/workspacesPanelProvider.ts deleted file mode 100644 index cac04d7cf6..0000000000 --- a/src/webviews/workspaces/workspacesPanelProvider.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as vscode from "vscode"; - -import { - buildCommandHandlers, - buildRequestHandlers, - WorkspacesApi, -} from "@repo/shared"; - -import { - dispatchCommand, - dispatchRequest, - isIpcCommand, - isIpcRequest, -} from "../dispatch"; -import { getWebviewHtml } from "../html"; - -import type { Logger } from "../../logging/logger"; - -export class WorkspacesPanelProvider - implements vscode.WebviewViewProvider, vscode.Disposable -{ - public static readonly viewType = "coder.workspacesPanel"; - - private view?: vscode.WebviewView; - private disposables: vscode.Disposable[] = []; - - private readonly requestHandlers = buildRequestHandlers(WorkspacesApi, {}); - private readonly commandHandlers = buildCommandHandlers(WorkspacesApi, {}); - - constructor( - private readonly extensionUri: vscode.Uri, - private readonly logger: Logger, - ) {} - - public refresh(): void { - this.logger.debug("Workspaces panel refresh requested"); - } - - resolveWebviewView( - webviewView: vscode.WebviewView, - _context: vscode.WebviewViewResolveContext, - _token: vscode.CancellationToken, - ): void { - this.view = webviewView; - - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: [ - vscode.Uri.joinPath( - this.extensionUri, - "dist", - "webviews", - "workspaces", - ), - ], - }; - - this.disposeView(); - - this.disposables.push( - webviewView.webview.onDidReceiveMessage((message: unknown) => { - this.handleMessage(message).catch((err: unknown) => { - this.logger.error("Unhandled error in message handler", err); - }); - }), - ); - - webviewView.webview.html = getWebviewHtml( - webviewView.webview, - this.extensionUri, - "workspaces", - "Coder Workspaces", - ); - - webviewView.onDidDispose(() => this.disposeView()); - } - - private async handleMessage(message: unknown): Promise { - if (isIpcRequest(message)) { - await dispatchRequest(message, this.requestHandlers, this.view?.webview, { - logger: this.logger, - }); - } else if (isIpcCommand(message)) { - await dispatchCommand(message, this.commandHandlers, { - logger: this.logger, - }); - } else { - this.logger.warn("Unexpected webview message", message); - } - } - - private disposeView(): void { - for (const d of this.disposables) { - d.dispose(); - } - this.disposables = []; - } - - dispose(): void { - this.disposeView(); - } -} diff --git a/src/workspace/agentMetadataTracker.ts b/src/workspace/agentMetadataTracker.ts new file mode 100644 index 0000000000..fb8b83b37f --- /dev/null +++ b/src/workspace/agentMetadataTracker.ts @@ -0,0 +1,179 @@ +import * as vscode from "vscode"; + +import { + createAgentMetadataWatcher, + formatMetadataError, + type AgentMetadataWatcher, + type AgentMetadataClient, +} from "../api/agentMetadataHelper"; + +import type { + AgentMetadataMap, + AgentMetadataState, + WorkspaceAgent, +} from "@repo/shared"; + +type AgentId = WorkspaceAgent["id"]; + +const PENDING: AgentMetadataState = { kind: "pending" }; + +/** One watched agent: its socket, and what it last reported. */ +class WatchedAgent { + /** Pending close, set while nothing watches the agent. */ + public linger: NodeJS.Timeout | undefined; + private current: AgentMetadataState = PENDING; + private watcher: AgentMetadataWatcher | undefined; + private opening: Promise | undefined; + private closed = false; + + constructor( + private readonly agentId: AgentId, + private readonly client: AgentMetadataClient, + private readonly onReport: (agent: WatchedAgent) => void, + ) {} + + public get state(): AgentMetadataState { + return this.current; + } + + /** Open the socket unless one is open or opening. Resolves once it settled. */ + public ensureOpen(): Promise { + if (!this.opening && (!this.watcher || this.watcher.closed)) { + this.opening = this.open().finally(() => { + this.opening = undefined; + }); + } + return this.opening ?? Promise.resolve(); + } + + public close(): void { + this.closed = true; + clearTimeout(this.linger); + this.watcher?.dispose(); + } + + private async open(): Promise { + let watcher: AgentMetadataWatcher; + try { + watcher = await createAgentMetadataWatcher(this.agentId, this.client); + } catch (error) { + this.report({ kind: "failed", error: formatMetadataError(error) }); + return; + } + if (this.closed) { + watcher.dispose(); + return; + } + this.watcher?.dispose(); + this.watcher = watcher; + watcher.onChange(() => this.reportFrom(watcher)); + // A report may arrive before the subscription. + if (watcher.metadata !== undefined || watcher.error !== undefined) { + this.reportFrom(watcher); + } + } + + private reportFrom(watcher: AgentMetadataWatcher): void { + this.report( + watcher.error === undefined + ? { kind: "reported", metadata: watcher.metadata ?? [] } + : { kind: "failed", error: formatMetadataError(watcher.error) }, + ); + } + + private report(state: AgentMetadataState): void { + if (!this.closed) { + this.current = state; + this.onReport(this); + } + } +} + +/** + * Watches agent metadata over SSE. The watched set is declared, not built up: + * `watch` opens the sockets it is missing and releases the rest. Released + * sockets stay open for a while, so watching an agent again is instant. + */ +export class AgentMetadataTracker implements vscode.Disposable { + private readonly changeEmitter = new vscode.EventEmitter(); + private readonly watched = new Map(); + + public readonly onDidChange = this.changeEmitter.event; + + private disposed = false; + + constructor( + private readonly client: AgentMetadataClient, + /** How long a released socket stays open, in case it is wanted again. */ + private readonly lingerMs = 15_000, + ) {} + + /** What every watched agent reports. Released agents are left out. */ + public get metadata(): AgentMetadataMap { + return Object.fromEntries( + [...this.watched] + .filter(([, agent]) => !agent.linger) + .map(([agentId, agent]) => [agentId, agent.state]), + ); + } + + /** + * Watch exactly `agentIds`, releasing the rest. A socket that fails to open + * is reported against its agent rather than thrown; the next call retries it. + */ + public async watch(agentIds: Iterable): Promise { + if (this.disposed) { + return; + } + const wanted = new Set(agentIds); + for (const [agentId, agent] of this.watched) { + if (wanted.has(agentId)) { + clearTimeout(agent.linger); + agent.linger = undefined; + } else { + agent.linger ??= setTimeout(() => this.close(agentId), this.lingerMs); + } + } + const opening = [...wanted].map((agentId) => + (this.watched.get(agentId) ?? this.add(agentId)).ensureOpen(), + ); + this.fire(); + await Promise.all(opening); + } + + /** Close every socket now, released or not. */ + public clear(): void { + for (const agentId of this.watched.keys()) { + this.close(agentId); + } + this.fire(); + } + + public dispose(): void { + this.disposed = true; + this.clear(); + this.changeEmitter.dispose(); + } + + private add(agentId: AgentId): WatchedAgent { + // A released agent's report is kept for when it is watched again. + const agent = new WatchedAgent(agentId, this.client, (reported) => { + if (!reported.linger) { + this.fire(); + } + }); + this.watched.set(agentId, agent); + return agent; + } + + private close(agentId: AgentId): void { + this.watched.get(agentId)?.close(); + this.watched.delete(agentId); + } + + private fire(): void { + if (!this.disposed) { + this.changeEmitter.fire(); + } + } +} diff --git a/src/workspace/filters.ts b/src/workspace/filters.ts new file mode 100644 index 0000000000..49fd0cca6a --- /dev/null +++ b/src/workspace/filters.ts @@ -0,0 +1,69 @@ +import { isAxiosError } from "axios"; + +import { isOwner } from "../api/api-helper"; + +import type { WorkspaceFilter } from "@repo/shared"; + +import type { SessionData, SignedInSession } from "../deployment/sessionStore"; + +/** How one set of workspaces is listed and shown, and who may select it. */ +export interface WorkspaceFilterConfig { + readonly requiresOwner: boolean; + /** How often to list again while showing. Absent when listed on demand. */ + readonly pollIntervalMs?: number; + /** Whether listed workspaces can belong to other users. */ + readonly showOwner: boolean; + /** Whether the tree watches the metadata of every listed agent. */ + readonly showMetadata: boolean; + readonly getQuery: (session: SignedInSession) => string; +} + +/** Keys are in display order. */ +export const WORKSPACE_FILTERS: Readonly< + Record +> = { + mine: { + requiresOwner: false, + pollIntervalMs: 5_000, + showOwner: false, + showMetadata: true, + getQuery: () => "owner:me", + }, + shared: { + requiresOwner: false, + showOwner: true, + showMetadata: false, + // Excludes workspaces the user owns and shared with others. + // Requires Coder 2.27.0+. + getQuery: (session) => `shared_with_user:${session.user.id}`, + }, + all: { + requiresOwner: true, + showOwner: true, + showMetadata: false, + getQuery: () => "", + }, +}; + +export const DEFAULT_WORKSPACE_FILTER: WorkspaceFilter = "mine"; + +/** The filters `session` may select, minus the ones `unsupported` lists. */ +export function availableFilters( + session: SessionData, + unsupported: ReadonlySet, +): readonly WorkspaceFilter[] { + if (session.kind !== "signedIn") { + return []; + } + const owner = isOwner(session.user); + return (Object.keys(WORKSPACE_FILTERS) as WorkspaceFilter[]).filter( + (filter) => + !unsupported.has(filter) && + (owner || !WORKSPACE_FILTERS[filter].requiresOwner), + ); +} + +/** True when the deployment cannot run a filter's query. */ +export function isQueryRejected(error: unknown): boolean { + return isAxiosError(error) && error.response?.status === 400; +} diff --git a/src/workspace/workspacesProvider.ts b/src/workspace/workspacesProvider.ts index b6b4e116ca..a8c203c01a 100644 --- a/src/workspace/workspacesProvider.ts +++ b/src/workspace/workspacesProvider.ts @@ -1,4 +1,3 @@ -import { isAxiosError } from "axios"; import { type Workspace, type WorkspaceAgent, @@ -21,45 +20,17 @@ import { import { type CoderApi } from "../api/coderApi"; import { type Logger } from "../logging/logger"; -import type { SessionData, SessionState } from "../deployment/sessionStore"; - -export enum WorkspaceQuery { - Mine = "mine", - All = "all", - Shared = "shared", -} - -type SignedInSession = Extract; +import { + isQueryRejected, + WORKSPACE_FILTERS, + type WorkspaceFilterConfig, +} from "./filters"; -/** Per-view rendering behavior and search query, keyed by workspace view. */ -interface WorkspaceQueryConfig { - readonly showOwner: boolean; - readonly showMetadata: boolean; - readonly getQuery: (session: SignedInSession) => string; -} +import type { WorkspaceFilter } from "@repo/shared"; -const WORKSPACE_QUERY_CONFIG = { - [WorkspaceQuery.Mine]: { - showOwner: false, - showMetadata: true, - getQuery: () => "owner:me", - }, - [WorkspaceQuery.All]: { - showOwner: true, - showMetadata: false, - getQuery: () => "", - }, - [WorkspaceQuery.Shared]: { - showOwner: true, - showMetadata: false, - // Only workspaces shared with the user; excludes workspaces the user - // owns and shared with others. Requires Coder 2.27.0+. - getQuery: (session) => `shared_with_user:${session.user.id}`, - }, -} as const satisfies Record; +import type { SessionData, SessionState } from "../deployment/sessionStore"; export interface WorkspaceProviderOptions { - readonly refreshIntervalMs?: number; /** * Called when the server rejects the workspaces query with HTTP 400, * which indicates a deployment that does not support the query filter. @@ -85,7 +56,7 @@ export class WorkspaceProvider AgentMetadataWatcher >(); private readonly sessionChangeDisposable: vscode.Disposable; - private readonly config: WorkspaceQueryConfig; + private readonly config: WorkspaceFilterConfig; private timeout: NodeJS.Timeout | undefined; private fetching = false; private refetchPending = false; @@ -93,13 +64,13 @@ export class WorkspaceProvider private disposed = false; constructor( - private readonly getWorkspacesQuery: WorkspaceQuery, + filter: WorkspaceFilter, private readonly client: CoderApi, private readonly logger: Logger, private readonly sessionState: SessionState, private readonly options: WorkspaceProviderOptions = {}, ) { - this.config = WORKSPACE_QUERY_CONFIG[getWorkspacesQuery]; + this.config = WORKSPACE_FILTERS[filter]; this.sessionChangeDisposable = this.sessionState.onDidChange(() => { this.clear(); void this.fetchAndRefresh(); @@ -135,7 +106,7 @@ export class WorkspaceProvider this.logger.warn("Failed to fetch workspaces:", error); hadError = true; this.setWorkspaces([]); - if (isAxiosError(error) && error.response?.status === 400) { + if (isQueryRejected(error)) { this.options.onQueryRejected?.(); } } @@ -258,12 +229,13 @@ export class WorkspaceProvider } } - /** Schedule the next poll, unless one is pending or no interval is set. */ + /** Schedule the next poll, unless one is pending or the filter never polls. */ private maybeScheduleRefresh() { - if (this.options.refreshIntervalMs && !this.timeout) { + const { pollIntervalMs } = this.config; + if (pollIntervalMs !== undefined && !this.timeout) { this.timeout = setTimeout(() => { void this.fetchAndRefresh(); - }, this.options.refreshIntervalMs); + }, pollIntervalMs); } } diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 708440cbb0..6808285630 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -10,7 +10,11 @@ import { onTestFinished, vi } from "vitest"; import * as vscode from "vscode"; import { Commands } from "@/commands"; -import { SessionStore, type SessionData } from "@/deployment/sessionStore"; +import { + SessionStore, + type SessionData, + type SignedInSession, +} from "@/deployment/sessionStore"; import { resource as createResource, @@ -720,6 +724,24 @@ export function setActiveColorTheme(kind: vscode.ColorThemeKind): void { vscodeWindow.__setActiveColorThemeKind(kind); } +/** A Webview that records what the extension posts to it. */ +function createMockWebview(options: vscode.WebviewOptions) { + const messageEmitter = new vscode.EventEmitter(); + const postedMessages: unknown[] = []; + const webview: vscode.Webview = { + options, + html: "", + cspSource: "mock-csp", + onDidReceiveMessage: messageEmitter.event, + postMessage: (msg: unknown) => { + postedMessages.push(msg); + return Promise.resolve(true); + }, + asWebviewUri: (uri: vscode.Uri) => uri, + }; + return { webview, messageEmitter, postedMessages }; +} + /** Hooks to drive lifecycle and inspect messages on a mocked WebviewPanel. */ export interface WebviewPanelTestHooks { setVisible(visible: boolean): void; @@ -746,25 +768,14 @@ export function createMockWebviewPanel( const viewStateEmitter = new vscode.EventEmitter(); const disposeEmitter = new vscode.EventEmitter(); - const messageEmitter = new vscode.EventEmitter(); + const { webview, messageEmitter, postedMessages } = createMockWebview( + options ?? { enableScripts: true, localResourceRoots: [] }, + ); const viewColumn = typeof showOptions === "object" ? showOptions.viewColumn : showOptions; - const postedMessages: unknown[] = []; let visible = true; - const webview: vscode.Webview = { - options: options ?? { enableScripts: true, localResourceRoots: [] }, - html: "", - cspSource: "mock-csp", - onDidReceiveMessage: messageEmitter.event, - postMessage: (msg) => { - postedMessages.push(msg); - return Promise.resolve(true); - }, - asWebviewUri: (uri) => uri, - }; - const panel: vscode.WebviewPanel = { viewType, title, @@ -803,6 +814,65 @@ export function createMockWebviewPanel( return { panel, hooks }; } +/** Hooks to drive lifecycle and inspect messages on a mocked WebviewView. */ +export interface WebviewViewTestHooks { + /** Flip `view.visible` and fire `onDidChangeVisibility`. */ + setVisible(visible: boolean): void; + fireDispose(): void; + /** Deliver a message as if the webview sent it. */ + sendFromWebview(msg: unknown): void; + /** Every message the extension posted to the webview, oldest first. */ + readonly postedMessages: readonly unknown[]; + clearPostedMessages(): void; +} + +/** + * Build a WebviewView for tests with real event emitters. Starts hidden + * unless `visible` says otherwise; drive it through the returned hooks. + */ +export function createMockWebviewView( + viewType: string, + options: { visible?: boolean } = {}, +): { view: vscode.WebviewView; hooks: WebviewViewTestHooks } { + const visibilityEmitter = new vscode.EventEmitter(); + const disposeEmitter = new vscode.EventEmitter(); + const { webview, messageEmitter, postedMessages } = createMockWebview({ + enableScripts: false, + localResourceRoots: [], + }); + let visible = options.visible ?? false; + + const view: vscode.WebviewView = { + viewType, + webview, + get visible() { + return visible; + }, + show: vi.fn(), + onDidChangeVisibility: visibilityEmitter.event, + onDidDispose: disposeEmitter.event, + }; + + const hooks: WebviewViewTestHooks = { + setVisible(next) { + visible = next; + visibilityEmitter.fire(); + }, + fireDispose() { + disposeEmitter.fire(); + }, + sendFromWebview(msg) { + messageEmitter.fire(msg); + }, + postedMessages, + clearPostedMessages() { + postedMessages.length = 0; + }, + }; + + return { view, hooks }; +} + export function createMockStream( content: string, options: { @@ -1048,6 +1118,20 @@ export function createMockUser(overrides: Partial = {}): User { }; } +/** A user holding `names` as their deployment-wide roles. */ +export function userWithRoles(...names: string[]): User { + return createMockUser({ + roles: names.map((name) => ({ name, display_name: name })), + }); +} + +/** A session signed in to TEST_DEPLOYMENT as `user`. */ +export function signedInSession( + user: User = createMockUser(), +): SignedInSession { + return { kind: "signedIn", deployment: TEST_DEPLOYMENT, user }; +} + /** * Creates an AxiosError for testing. */ @@ -1414,6 +1498,12 @@ export class MockTerminalOutputChannel { /** Default user id of TestSessionStore's initial signed-in session. */ export const TEST_CURRENT_USER_ID = "current-user"; +/** The deployment tests sign in to. */ +export const TEST_DEPLOYMENT: Deployment = { + url: "https://coder.example.com", + safeHostname: "coder.example.com", +}; + /** * Real SessionStore that starts signed in as TEST_CURRENT_USER_ID, with * id-only sign-in/out helpers for tests that switch users. @@ -1425,10 +1515,7 @@ export class TestSessionStore extends SessionStore { } signInAs(userId = TEST_CURRENT_USER_ID): void { - this.signIn( - { url: "https://coder.example.com", safeHostname: "coder.example.com" }, - createMockUser({ id: userId }), - ); + this.signIn(TEST_DEPLOYMENT, createMockUser({ id: userId })); } override signOut(deployment: Deployment | null = null): SessionData { @@ -1441,6 +1528,11 @@ interface WorkspacesResponse { count: number; } +/** A metadata socket, as MockWorkspacesClient hands one out. */ +export type MockMetadataStream = MockEventStream<{ + data: AgentMetadataEvent[]; +}>; + /** * Stands in for CoderApi at the boundaries WorkspaceProvider touches: the * workspaces query and the per-agent metadata socket. Program responses with @@ -1448,22 +1540,30 @@ interface WorkspacesResponse { * tests drive the production watcher rather than a stub. */ export class MockWorkspacesClient { - readonly metadataStreams = new Map< - string, - MockEventStream<{ data: AgentMetadataEvent[] }> - >(); + readonly metadataStreams = new Map(); readonly getWorkspaces = vi.fn( (_req: { q: string }): Promise => Promise.resolve({ workspaces: [], count: 0 }), ); + readonly getHost = vi.fn((): string | undefined => TEST_DEPLOYMENT.url); + watchAgentMetadata(agentId: string) { - const stream = new MockEventStream<{ data: AgentMetadataEvent[] }>(); + const stream: MockMetadataStream = new MockEventStream(); this.metadataStreams.set(agentId, stream); return Promise.resolve(stream); } + /** The socket opened for `agentId`, failing the test when there is none. */ + metadataStream(agentId: string): MockMetadataStream { + const stream = this.metadataStreams.get(agentId); + if (!stream) { + throw new Error(`No metadata socket for ${agentId}`); + } + return stream; + } + /** Resolve the next getWorkspaces call with these workspaces. */ respondOnce(workspaces: readonly Workspace[]): void { this.getWorkspaces.mockResolvedValueOnce({ @@ -1472,13 +1572,18 @@ export class MockWorkspacesClient { }); } - /** Make the next getWorkspaces call hang until the returned resolve() runs. */ - pending(): { resolve: (workspaces: readonly Workspace[]) => void } { - const { promise, resolve } = Promise.withResolvers(); + /** Make the next getWorkspaces call hang until the test settles it. */ + pending(): { + resolve: (workspaces: readonly Workspace[]) => void; + reject: (error: unknown) => void; + } { + const { promise, resolve, reject } = + Promise.withResolvers(); this.getWorkspaces.mockReturnValueOnce(promise); return { resolve: (workspaces) => resolve({ workspaces, count: workspaces.length }), + reject, }; } } diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index d63b2efcf7..323d35fe55 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -174,6 +174,28 @@ export class EventEmitter { } } +/** Mock CancellationTokenSource that matches the vscode API. */ +export class CancellationTokenSource { + private readonly emitter = new EventEmitter(); + + readonly token = { + isCancellationRequested: false, + onCancellationRequested: this.emitter.event, + }; + + cancel(): void { + if (this.token.isCancellationRequested) { + return; + } + this.token.isCancellationRequested = true; + this.emitter.fire(); + } + + dispose(): void { + this.emitter.dispose(); + } +} + const onDidChangeConfiguration = new EventEmitter(); const onDidChangeWorkspaceFolders = new EventEmitter(); const onDidChangeActiveColorTheme = new EventEmitter(); @@ -279,6 +301,7 @@ const vscode = { InputBoxValidationSeverity, Uri, EventEmitter, + CancellationTokenSource, MarkdownString, ThemeColor, TreeItem, diff --git a/test/tsconfig.json b/test/tsconfig.json index 23b4b0a00e..6c1e8a50e7 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -16,7 +16,8 @@ "@repo/ui": ["../packages/ui/src/index.ts"], "@repo/ui/*": ["../packages/ui/src/*"], "@repo/netcheck/*": ["../packages/netcheck/src/*"], - "@repo/speedtest/*": ["../packages/speedtest/src/*"] + "@repo/speedtest/*": ["../packages/speedtest/src/*"], + "@repo/workspaces/*": ["../packages/workspaces/src/*"] } }, "include": [".", "../src", "../vitest.config.mts"] diff --git a/test/unit/api/api-helper.test.ts b/test/unit/api/api-helper.test.ts new file mode 100644 index 0000000000..e9da05929f --- /dev/null +++ b/test/unit/api/api-helper.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { isOwner } from "@/api/api-helper"; + +import { userWithRoles } from "../../mocks/testHelpers"; + +describe("isOwner", () => { + interface OwnerCase { + name: string; + roles: string[] | undefined; + owner: boolean; + } + + it.each([ + { name: "nobody", roles: undefined, owner: false }, + { name: "a member", roles: ["member"], owner: false }, + { name: "an owner among others", roles: ["auditor", "owner"], owner: true }, + ])("is $owner for $name", ({ roles, owner }) => { + expect(isOwner(roles ? userWithRoles(...roles) : undefined)).toBe(owner); + }); +}); diff --git a/test/unit/util/poller.test.ts b/test/unit/util/poller.test.ts new file mode 100644 index 0000000000..6ef48ef9d7 --- /dev/null +++ b/test/unit/util/poller.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest"; + +import { Poller, type NextRun, type RetryOptions } from "@/util/poller"; + +import { createMockLogger, flushPromises } from "../../mocks/testHelpers"; + +import type * as vscode from "vscode"; + +const RETRY: RetryOptions = { initialDelayMs: 1_000, maxDelayMs: 4_000 }; +const POLL: NextRun = { delayMs: 1_000 }; + +/** A poller whose task answers with `results`, oldest first; the rest hang. */ +function setup(...results: NextRun[]) { + vi.useFakeTimers(); + const tokens: vscode.CancellationToken[] = []; + const hanging: Array> = []; + const task = vi.fn((token: vscode.CancellationToken) => { + tokens.push(token); + const next = results.shift(); + if (next !== undefined) { + return Promise.resolve(next); + } + const run = Promise.withResolvers(); + hanging.push(run); + return run.promise; + }); + const logger = createMockLogger(); + const poller = new Poller(task, RETRY, logger); + onTestFinished(() => { + poller.dispose(); + vi.useRealTimers(); + }); + return { + poller, + task, + tokens, + logger, + /** Settle the oldest run still hanging. */ + finish: (next: NextRun) => { + hanging.shift()?.resolve(next); + return flushPromises(); + }, + fail: (error: Error) => { + hanging.shift()?.reject(error); + return flushPromises(); + }, + }; +} + +describe("Poller", () => { + interface RunsCase { + name: string; + result: NextRun; + runs: number; + } + + it.each([ + { name: "a delay", result: POLL, runs: 2 }, + { name: "retry", result: "retry", runs: 2 }, + { name: "idle", result: "idle", runs: 1 }, + ])("answering $name runs the task $runs times", async ({ result, runs }) => { + const h = setup(result, "idle"); + await h.poller.run(); + await vi.advanceTimersByTimeAsync(POLL.delayMs); + expect(h.task).toHaveBeenCalledTimes(runs); + }); + + it("backs off while the task keeps failing, and starts over after a success", async () => { + const h = setup("retry", "retry", "retry", "retry", POLL, "retry", "idle"); + await h.poller.run(); + // One initial delay, then doubling, then held at the cap. + for (const [delayMs, runs] of [ + [1_000, 2], + [2_000, 3], + [4_000, 4], + [4_000, 5], + ]) { + await vi.advanceTimersByTimeAsync(delayMs); + expect(h.task).toHaveBeenCalledTimes(runs); + } + // The poll that succeeded reset the backoff. + await vi.advanceTimersByTimeAsync(POLL.delayMs + 1_000); + expect(h.task).toHaveBeenCalledTimes(7); + }); + + it("retries a task that throws, and logs why", async () => { + const h = setup(); + const running = h.poller.run(); + await h.fail(new Error("boom")); + await running; + await vi.advanceTimersByTimeAsync(1_000); + expect(h.task).toHaveBeenCalledTimes(2); + expect(h.logger.error).toHaveBeenCalled(); + }); + + it("hands each run its own token, cancelling the one it supersedes", async () => { + const h = setup(); + const superseded = h.poller.run(); + void h.poller.run(); + expect(h.tokens.map((t) => t.isCancellationRequested)).toEqual([ + true, + false, + ]); + + // What a superseded run answers schedules nothing. + await h.finish(POLL); + await superseded; + await vi.advanceTimersByTimeAsync(4_000); + expect(h.task).toHaveBeenCalledTimes(2); + }); + + it("schedules nothing once disposed, pending or in flight", async () => { + const h = setup(POLL); + await h.poller.run(); + const running = h.poller.run(); + h.poller.dispose(); + expect(h.tokens[1].isCancellationRequested).toBe(true); + await h.finish(POLL); + await running; + await vi.advanceTimersByTimeAsync(4_000); + expect(h.task).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/unit/webviews/dispatch.test.ts b/test/unit/webviews/dispatch.test.ts index 33eeed9286..77c196f074 100644 --- a/test/unit/webviews/dispatch.test.ts +++ b/test/unit/webviews/dispatch.test.ts @@ -88,11 +88,11 @@ describe("dispatchCommand", () => { expect(showError).not.toHaveBeenCalled(); }); - it("shows errors when showErrorToUser opts in", async () => { + it("shows errors for methods listed as user actions", async () => { await dispatchCommand( { method: "do" }, { do: vi.fn().mockRejectedValue(new Error("kaboom")) }, - { logger, showErrorToUser: () => true }, + { logger, userActions: new Set(["do"]) }, ); expect(showError).toHaveBeenCalledWith("kaboom"); }); @@ -147,7 +147,7 @@ describe("dispatchRequest", () => { { requestId: "r4", method: "delete" }, { delete: vi.fn().mockRejectedValue(new Error("nope")) }, webview, - { logger, showErrorToUser: (m) => m === "delete" }, + { logger, userActions: new Set(["delete"]) }, ); expect(showError).toHaveBeenCalledWith("nope"); }); diff --git a/test/unit/webviews/tasks/tasksPanelProvider.test.ts b/test/unit/webviews/tasks/tasksPanelProvider.test.ts index 1e73477d18..8850e6e835 100644 --- a/test/unit/webviews/tasks/tasksPanelProvider.test.ts +++ b/test/unit/webviews/tasks/tasksPanelProvider.test.ts @@ -23,6 +23,7 @@ import { import { createAxiosError, createMockLogger, + createMockWebviewView, MockConfigurationProvider, MockUserInteraction, } from "../../../mocks/testHelpers"; @@ -129,33 +130,10 @@ function createHarness(): Harness { createMockLogger(), ); - const posted: unknown[] = []; - let handler: ((msg: unknown) => void) | null = null; - - const webview: vscode.WebviewView = { - viewType: "coder.tasksPanel", - webview: { - options: { enableScripts: false, localResourceRoots: [] }, - html: "", - cspSource: "", - postMessage: vi.fn((msg: unknown) => { - posted.push(msg); - return Promise.resolve(true); - }), - onDidReceiveMessage: vi.fn((h) => { - handler = h; - return { dispose: vi.fn() }; - }), - asWebviewUri: vi.fn((uri: vscode.Uri) => uri), - }, - visible: true, - show: vi.fn(), - onDidChangeVisibility: vi.fn(() => ({ dispose: vi.fn() })), - onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), - }; + const { view, hooks } = createMockWebviewView("coder.tasksPanel"); panel.resolveWebviewView( - webview, + view, {} as vscode.WebviewViewResolveContext, {} as vscode.CancellationToken, ); @@ -164,19 +142,19 @@ function createHarness(): Harness { panel, client, ui, - messages: () => [...posted], + messages: () => [...hooks.postedMessages], request: async ( def: RequestDef, ...args: P extends void ? [] : [params: P] ) => { const params = args[0]; const requestId = `req-${Date.now()}-${Math.random()}`; - handler?.({ requestId, method: def.method, params }); + hooks.sendFromWebview({ requestId, method: def.method, params }); await vi.waitFor( () => { if ( - !posted.some( + !hooks.postedMessages.some( (m) => (m as { requestId?: string }).requestId === requestId, ) ) { @@ -186,7 +164,7 @@ function createHarness(): Harness { { timeout: 1000 }, ); - return posted.find( + return hooks.postedMessages.find( (m) => (m as { requestId?: string }).requestId === requestId, ) as { success: boolean; data?: R; error?: string }; }, @@ -194,7 +172,7 @@ function createHarness(): Harness { def: CommandDef

, ...args: P extends void ? [] : [params: P] ) => { - handler?.({ method: def.method, params: args[0] }); + hooks.sendFromWebview({ method: def.method, params: args[0] }); await new Promise((r) => setTimeout(r, 10)); }, }; diff --git a/test/unit/webviews/workspaces/panelProvider.test.ts b/test/unit/webviews/workspaces/panelProvider.test.ts new file mode 100644 index 0000000000..c0bbfe271e --- /dev/null +++ b/test/unit/webviews/workspaces/panelProvider.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import * as vscode from "vscode"; + +import { WorkspacesPanelProvider } from "@/webviews/workspaces/panelProvider"; +import { WorkspaceStore } from "@/webviews/workspaces/workspaceStore"; + +import { + agent, + agentMetadata, + PENDING_METADATA, + REPORTED_METADATA, + workspace, +} from "@repo/mocks"; +import { + WorkspacesApi, + type CommandDef, + type OpenWorkspaceParams, + type WorkspacesState, +} from "@repo/shared"; + +import { + createMockLogger, + createMockWebviewView, + flushPromises, + MockCancellationToken, + MockConfigurationProvider, + MockWorkspacesClient, + TestSessionStore, +} from "../../../mocks/testHelpers"; + +import type { Workspace } from "coder/site/src/api/typesGenerated"; + +import type { Commands } from "@/commands"; + +const alice = () => + workspace({ + id: "workspace-1", + name: "dev", + owner_name: "alice", + agents: [agent({ id: "agent-1", name: "main" })], + }); + +const isState = (m: unknown): m is { data: WorkspacesState } => + (m as { type?: string }).type === WorkspacesApi.stateChanged.method; + +/** Each state as `[status, workspace ids]`, so a list is easy to read. */ +const listings = (states: WorkspacesState[]) => + states.map((s) => [s.status.kind, s.workspaces.map((w) => w.id)]); + +function setup() { + vi.clearAllMocks(); + new MockConfigurationProvider(); + const client = new MockWorkspacesClient(); + const store = new WorkspaceStore( + client, + createMockLogger(), + new TestSessionStore(), + ); + const commands = { + openWorkspaceFromSidebar: vi.fn(() => Promise.resolve()), + openWorkspaceInDashboard: vi.fn(() => Promise.resolve()), + } satisfies Pick< + Commands, + "openWorkspaceFromSidebar" | "openWorkspaceInDashboard" + >; + const provider = new WorkspacesPanelProvider( + vscode.Uri.file("/test"), + store, + commands, + createMockLogger(), + ); + onTestFinished(() => { + provider.dispose(); + store.dispose(); + }); + + /** Resolve a view, as VS Code does when the panel is (re)opened. */ + const resolveView = ( + options: { cancelled?: boolean; visible?: boolean } = {}, + ) => { + const { view, hooks } = createMockWebviewView( + WorkspacesPanelProvider.viewType, + options, + ); + provider.resolveWebviewView( + view, + {} as vscode.WebviewViewResolveContext, + new MockCancellationToken(options.cancelled), + ); + return { + view, + hooks, + /** Send a command from the webview and wait for its handler. */ + send: async

( + def: CommandDef

, + ...args: P extends void ? [] : [params: P] + ) => { + hooks.sendFromWebview({ method: def.method, params: args[0] }); + await flushPromises(); + await store.settled; + }, + pushed: (): WorkspacesState[] => + hooks.postedMessages.filter(isState).map(({ data }) => data), + }; + }; + + return { client, store, commands, provider, resolveView, ...resolveView() }; +} + +/** A visible panel, answering its first lists with `responses`, in order. */ +async function shown(...responses: Workspace[][]) { + const h = setup(); + responses.forEach((listed) => h.client.respondOnce(listed)); + h.hooks.setVisible(true); + await h.store.settled; + h.hooks.clearPostedMessages(); + return h; +} + +type Panel = Awaited>; + +describe("WorkspacesPanelProvider", () => { + it("renders the bundle, and answers ready with the state it has", async () => { + const h = await shown([alice()]); + expect(h.view.webview.html).toContain("Coder Workspaces"); + await h.send(WorkspacesApi.ready); + expect(listings(h.pushed())).toEqual([["ready", ["workspace-1"]]]); + expect(h.client.getWorkspaces).toHaveBeenCalledTimes(1); + }); + + it("pushes the whole state on every change", async () => { + const h = await shown(); + h.client.respondOnce([alice()]); + await h.send(WorkspacesApi.refresh); + expect(listings(h.pushed())).toEqual([ + ["loading", []], + ["ready", ["workspace-1"]], + ]); + }); + + it("pushes only the fresh list on reveal, since the webview kept the rest", async () => { + const h = await shown(); + h.hooks.setVisible(false); + h.client.respondOnce([alice()]); + h.hooks.setVisible(true); + await h.store.settled; + expect(listings(h.pushed())).toEqual([["ready", ["workspace-1"]]]); + }); + + it("keeps a hidden webview current", async () => { + const h = await shown([alice()]); + await h.send(WorkspacesApi.watchAgents, { agentIds: ["agent-1"] }); + h.hooks.setVisible(false); + h.hooks.clearPostedMessages(); + h.client.metadataStream("agent-1").pushMessage({ data: [agentMetadata()] }); + expect(h.pushed().map((s) => s.metadata)).toEqual([ + { "agent-1": REPORTED_METADATA }, + ]); + }); + + it("stops watching the agents a webview expanded once it reloads", async () => { + const h = await shown([alice()]); + await h.send(WorkspacesApi.watchAgents, { agentIds: ["agent-1"] }); + await h.send(WorkspacesApi.ready); + expect(h.pushed().at(-1)?.metadata).toEqual({}); + expect(h.store.state.metadata).toEqual({}); + }); + + describe("lifecycle", () => { + interface TeardownCase { + name: string; + teardown: (h: Panel) => void; + } + + it.each([ + { + name: "the provider is disposed", + teardown: (h) => h.provider.dispose(), + }, + { name: "the view is destroyed", teardown: (h) => h.hooks.fireDispose() }, + ])("stops listing and pushing when $name", async ({ teardown }) => { + const h = await shown(); + teardown(h); + // Only a store that was left hidden lists again when revealed. + await h.store.setVisible(true); + expect(h.client.getWorkspaces).toHaveBeenCalledTimes(2); + expect(h.pushed()).toEqual([]); + }); + + it("hands the panel to the newest view, even once the old one is destroyed", async () => { + const h = await shown(); + const next = h.resolveView(); + await h.send(WorkspacesApi.ready); + h.hooks.fireDispose(); + await next.send(WorkspacesApi.ready); + expect(h.pushed()).toEqual([]); + expect(listings(next.pushed())).toEqual([["ready", []]]); + }); + + it("keeps listing and watching when a visible view replaces another", async () => { + const h = await shown([alice()]); + await h.send(WorkspacesApi.watchAgents, { agentIds: ["agent-1"] }); + h.resolveView({ visible: true }); + expect(h.client.getWorkspaces).toHaveBeenCalledTimes(1); + expect(h.store.state.metadata).toEqual({ "agent-1": PENDING_METADATA }); + }); + + it("resolves nothing once the request is cancelled", async () => { + const h = await shown(); + const cancelled = h.resolveView({ cancelled: true }); + expect(cancelled.view.webview.html).toBe(""); + await cancelled.send(WorkspacesApi.ready); + expect(cancelled.pushed()).toEqual([]); + await h.send(WorkspacesApi.ready); + expect(h.pushed()).toHaveLength(1); + }); + }); + + it("ignores an unrecognized message", async () => { + const h = await shown(); + h.hooks.sendFromWebview({ method: "nope" }); + await flushPromises(); + expect(h.pushed()).toEqual([]); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + }); + + it("forwards commands to the store, and dashboard pages to Commands", async () => { + const h = await shown([alice()]); + await h.send(WorkspacesApi.watchAgents, { agentIds: ["agent-1"] }); + expect([...h.client.metadataStreams.keys()]).toEqual(["agent-1"]); + + const params = { workspaceId: "workspace-1", page: "settings" } as const; + await h.send(WorkspacesApi.viewInDashboard, params); + expect(h.commands.openWorkspaceInDashboard).toHaveBeenCalledWith( + expect.objectContaining({ name: "dev" }), + "settings", + ); + + await h.send(WorkspacesApi.setFilter, { filter: "shared" }); + expect(h.store.state.filter).toBe("shared"); + }); + + interface OpenCase { + name: string; + params: OpenWorkspaceParams; + agent?: string; + } + + it.each([ + { name: "the workspace", params: { workspaceId: "workspace-1" } }, + { + name: "one of its agents", + params: { workspaceId: "workspace-1", agentId: "agent-1" }, + agent: "main", + }, + ])("opens $name", async ({ params, agent: agentName }) => { + const h = await shown([alice()]); + await h.send(WorkspacesApi.openWorkspace, params); + expect(h.commands.openWorkspaceFromSidebar).toHaveBeenCalledWith( + expect.objectContaining({ name: "dev", owner_name: "alice" }), + agentName && expect.objectContaining({ name: agentName }), + ); + }); + + interface FailedOpenCase { + name: string; + params: OpenWorkspaceParams; + reported: string; + } + + it.each([ + { + name: "a workspace that is gone", + params: { workspaceId: "gone" }, + reported: "Workspace is no longer available", + }, + { + name: "an agent that is gone", + params: { workspaceId: "workspace-1", agentId: "gone" }, + reported: "Agent is no longer available", + }, + { + name: "a connection that failed", + params: { workspaceId: "workspace-1" }, + reported: "no SSH binary", + }, + ])("reports opening $name", async ({ params, reported }) => { + const h = await shown([alice()]); + h.commands.openWorkspaceFromSidebar.mockRejectedValueOnce( + new Error(reported), + ); + await h.send(WorkspacesApi.openWorkspace, params); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(reported); + }); +}); diff --git a/test/unit/webviews/workspaces/workspaceStore.test.ts b/test/unit/webviews/workspaces/workspaceStore.test.ts new file mode 100644 index 0000000000..db2662adc6 --- /dev/null +++ b/test/unit/webviews/workspaces/workspaceStore.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest"; + +import { WorkspaceStore } from "@/webviews/workspaces/workspaceStore"; + +import { + agent, + agentMetadata, + REPORTED_METADATA, + workspace, +} from "@repo/mocks"; + +import { + createAxiosError, + createMockLogger, + MockWorkspacesClient, + TEST_DEPLOYMENT, + TestSessionStore, + userWithRoles, +} from "../../../mocks/testHelpers"; + +import type { Workspace, WorkspacesState } from "@repo/shared"; + +/** The 400 a deployment answers with when it cannot run a filter's query. */ +const queryRejected = () => createAxiosError(400, "invalid query"); + +const withAgent = () => + workspace({ id: "workspace-1", agents: [agent({ id: "agent-1" })] }); + +const ids = (state?: WorkspacesState) => state?.workspaces.map((w) => w.id); + +function setup() { + const client = new MockWorkspacesClient(); + const session = new TestSessionStore(); + const store = new WorkspaceStore(client, createMockLogger(), session); + onTestFinished(() => store.dispose()); + const states: WorkspacesState[] = []; + store.onDidChange((state) => states.push(state)); + return { + client, + session, + store, + states, + last: () => states.at(-1), + /** Answer the next lists with `responses`, then reveal the store. */ + show: (...responses: Workspace[][]) => { + responses.forEach((listed) => client.respondOnce(listed)); + return store.setVisible(true); + }, + }; +} + +type Store = ReturnType; + +async function shown(...responses: Workspace[][]) { + const h = setup(); + await h.show(...responses); + return h; +} + +async function watchingAgent() { + const h = await shown([withAgent()]); + await h.store.watchAgents(["agent-1"]); + return h; +} + +const pushMetadata = (h: Store) => + h.client.metadataStream("agent-1").pushMessage({ data: [agentMetadata()] }); + +describe("WorkspaceStore", () => { + it("lists the signed-in user's workspaces once visible", async () => { + const h = setup(); + await h.store.settled; + expect(h.states).toEqual([]); + expect(h.store.state.status).toEqual({ kind: "loading" }); + + await h.show([workspace({ id: "workspace-1" })]); + expect(h.client.getWorkspaces).toHaveBeenCalledWith({ q: "owner:me" }); + expect(h.states).toEqual([ + { + capabilities: { authenticated: true, filters: ["mine", "shared"] }, + filter: "mine", + workspaces: [expect.objectContaining({ id: "workspace-1" })], + status: { kind: "ready" }, + metadata: {}, + }, + ]); + expect(h.store.findWorkspace("workspace-1")).toBeDefined(); + expect(h.store.findWorkspace("gone")).toBeUndefined(); + }); + + it("polls the filters that poll, saying nothing when nothing changed", async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const second = () => [workspace({ id: "second" })]; + const h = await shown([workspace({ id: "first" })], second(), second()); + + await vi.advanceTimersByTimeAsync(5_000); + expect(h.states.map(ids)).toEqual([["first"], ["second"]]); + await vi.advanceTimersByTimeAsync(5_000); + expect(h.states).toHaveLength(2); + + await h.store.setFilter("shared"); + const fetches = h.client.getWorkspaces.mock.calls.length; + await vi.advanceTimersByTimeAsync(60_000); + expect(h.client.getWorkspaces).toHaveBeenCalledTimes(fetches); + }); + + interface LoadingCase { + name: string; + act: (h: Store) => Promise; + } + + it.each([ + { name: "a filter switch", act: (h) => h.store.setFilter("shared") }, + { name: "a refresh", act: (h) => h.store.refresh() }, + ])("reports loading again for $name", async ({ act }) => { + const h = await shown(); + await act(h); + expect(h.states.map((s) => s.status.kind)).toEqual([ + "ready", + "loading", + "ready", + ]); + }); + + it("lists the filter the webview selected, ignoring ones it does not offer", async () => { + const h = await shown(); + await h.store.setFilter("all"); + expect(h.client.getWorkspaces).toHaveBeenCalledTimes(1); + + await h.store.setFilter("shared"); + expect(h.client.getWorkspaces).toHaveBeenLastCalledWith({ + q: "shared_with_user:current-user", + }); + expect(h.last()?.filter).toBe("shared"); + }); + + it("starts over for each session, offering the filters of whoever signed in", async () => { + const h = await shown(); + h.session.signIn(TEST_DEPLOYMENT, userWithRoles("owner")); + await h.store.settled; + expect(h.last()?.capabilities.filters).toEqual(["mine", "shared", "all"]); + await h.store.setFilter("all"); + + h.session.signInAs("someone-else"); + await h.store.settled; + expect(h.last()).toMatchObject({ + capabilities: { filters: ["mine", "shared"] }, + filter: "mine", + workspaces: [], + }); + + const fetches = h.client.getWorkspaces.mock.calls.length; + h.session.signOut(); + await h.store.settled; + expect(h.client.getWorkspaces).toHaveBeenCalledTimes(fetches); + expect(h.last()).toMatchObject({ + capabilities: { authenticated: false, filters: [] }, + status: { kind: "ready" }, + }); + }); + + describe("failed fetches", () => { + it("reports a failure, then clears it once a fetch succeeds", async () => { + const h = await shown([workspace({ id: "workspace-1" })]); + h.client.getWorkspaces.mockRejectedValueOnce(new Error("network down")); + await h.store.refresh(); + expect(h.last()).toMatchObject({ + workspaces: [], + status: { kind: "failed", error: "network down" }, + }); + + await h.store.refresh(); + expect(h.last()?.status).toEqual({ kind: "ready" }); + }); + + it("stops offering a filter the deployment rejects, until a visible refresh", async () => { + const h = await shown(); + h.client.getWorkspaces.mockRejectedValueOnce(queryRejected()); + h.client.respondOnce([workspace({ id: "workspace-1" })]); + await h.store.setFilter("shared"); + // Falls back to a filter that loads instead of leaving a broken one. + expect(h.last()).toMatchObject({ + capabilities: { filters: ["mine"] }, + filter: "mine", + workspaces: [expect.objectContaining({ id: "workspace-1" })], + status: { kind: "ready" }, + }); + + await h.store.setVisible(false); + await h.store.refresh(); + expect(h.store.state.capabilities.filters).toEqual(["mine"]); + + await h.store.setVisible(true); + await h.store.refresh(); + expect(h.store.state.capabilities.filters).toEqual(["mine", "shared"]); + }); + + it("keeps the default filter when its query is rejected", async () => { + const h = setup(); + h.client.getWorkspaces.mockRejectedValueOnce(queryRejected()); + await h.show(); + expect(h.store.state).toMatchObject({ + capabilities: { filters: ["mine", "shared"] }, + status: { kind: "failed", error: "invalid query" }, + }); + }); + }); + + describe("agent metadata", () => { + it("watches the listed agents the webview asks for, and reports what arrives", async () => { + const h = await shown([withAgent()]); + expect(h.client.metadataStreams.size).toBe(0); + await h.store.watchAgents(["agent-1", "not-listed"]); + expect([...h.client.metadataStreams.keys()]).toEqual(["agent-1"]); + + pushMetadata(h); + expect(h.last()?.metadata).toEqual({ "agent-1": REPORTED_METADATA }); + }); + + it("keeps the sockets and their last report across a hide", async () => { + const h = await watchingAgent(); + pushMetadata(h); + const opened = vi.spyOn(h.client, "watchAgentMetadata"); + await h.store.setVisible(false); + await h.show([withAgent()]); + expect(opened).not.toHaveBeenCalled(); + expect(h.last()?.metadata).toEqual({ "agent-1": REPORTED_METADATA }); + }); + + it("keeps the workspaces when a metadata socket fails", async () => { + const h = setup(); + vi.spyOn(h.client, "watchAgentMetadata").mockRejectedValue( + new Error("socket refused"), + ); + await h.show([withAgent()]); + await h.store.watchAgents(["agent-1"]); + expect(h.last()).toMatchObject({ + workspaces: [expect.objectContaining({ id: "workspace-1" })], + status: { kind: "ready" }, + metadata: { + "agent-1": { + kind: "failed", + error: "Failed to query metadata: socket refused", + }, + }, + }); + }); + + it("releases agents a failed list drops, and closes them once the session ends", async () => { + const h = await watchingAgent(); + const socket = h.client.metadataStream("agent-1"); + h.client.getWorkspaces.mockRejectedValueOnce(new Error("offline")); + await h.store.refresh(); + expect(h.store.state.metadata).toEqual({}); + expect(socket.close).not.toHaveBeenCalled(); + + h.session.signOut(); + expect(socket.close).toHaveBeenCalledOnce(); + }); + }); + + describe("requests that outlive a hide", () => { + it("caches a response, and keeps it on reveal while the fresh list loads", async () => { + const h = await shown(); + const pending = h.client.pending(); + const fetching = h.store.refresh(); + void h.store.setVisible(false); + pending.resolve([workspace({ id: "cached" })]); + await fetching; + expect(ids(h.store.state)).toEqual(["cached"]); + + const next = h.client.pending(); + const revealing = h.show(); + expect(h.store.state).toMatchObject({ + workspaces: [expect.objectContaining({ id: "cached" })], + status: { kind: "ready" }, + }); + next.resolve([]); + await revealing; + }); + + interface SettleCase { + name: string; + settle: (pending: ReturnType) => void; + } + + it.each([ + { name: "resolves", settle: (p) => p.resolve([withAgent()]) }, + { name: "rejects", settle: (p) => p.reject(new Error("old failure")) }, + ])("ignores an old session's request when it $name", async ({ settle }) => { + const h = setup(); + const pending = h.client.pending(); + const fetching = h.show(); + void h.store.setVisible(false); + h.session.signOut(); + settle(pending); + await fetching; + expect(h.store.state).toMatchObject({ + capabilities: { authenticated: false }, + workspaces: [], + status: { kind: "ready" }, + metadata: {}, + }); + }); + + it("invalidates a request when its filter changes", async () => { + const h = setup(); + const pending = h.client.pending(); + const fetching = h.show(); + void h.store.setVisible(false); + await h.store.setFilter("shared"); + pending.resolve([withAgent()]); + await fetching; + // Nothing lists while hidden, so the new filter is still loading. + expect(h.store.state).toMatchObject({ + filter: "shared", + workspaces: [], + status: { kind: "loading" }, + }); + }); + }); +}); diff --git a/test/unit/workspace/agentMetadataTracker.test.ts b/test/unit/workspace/agentMetadataTracker.test.ts new file mode 100644 index 0000000000..66945779b4 --- /dev/null +++ b/test/unit/workspace/agentMetadataTracker.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest"; + +import { AgentMetadataTracker } from "@/workspace/agentMetadataTracker"; + +import { + agentMetadata, + PENDING_METADATA, + REPORTED_METADATA, +} from "@repo/mocks"; + +import { + MockEventStream, + MockWorkspacesClient, + type MockMetadataStream, +} from "../../mocks/testHelpers"; + +import type { AgentMetadataMap, AgentMetadataState } from "@repo/shared"; + +const LINGER_MS = 1_000; + +const failed = (cause: string): AgentMetadataState => ({ + kind: "failed", + error: `Failed to query metadata: ${cause}`, +}); + +const died = { code: 1006, reason: "", wasClean: false }; + +/** A tracker watching `agents`, on fake timers, spying on the sockets it opens. */ +async function setup(...agents: string[]) { + vi.useFakeTimers(); + const client = new MockWorkspacesClient(); + const tracker = new AgentMetadataTracker(client, LINGER_MS); + onTestFinished(() => { + tracker.dispose(); + vi.useRealTimers(); + }); + const reports: AgentMetadataMap[] = []; + tracker.onDidChange(() => reports.push(tracker.metadata)); + const opened = vi.spyOn(client, "watchAgentMetadata"); + await tracker.watch(agents); + return { + client, + tracker, + reports, + opened, + stream: client.metadataStream.bind(client), + }; +} + +describe("AgentMetadataTracker", () => { + it("reports the agents it was given, with one socket each", async () => { + const { opened, tracker, reports } = await setup("agent-1", "agent-2"); + await tracker.watch(["agent-2", "agent-3"]); + expect(reports).toEqual([ + { "agent-1": PENDING_METADATA, "agent-2": PENDING_METADATA }, + { "agent-2": PENDING_METADATA, "agent-3": PENDING_METADATA }, + ]); + expect(opened.mock.calls.map(([id]) => id)).toEqual([ + "agent-1", + "agent-2", + "agent-3", + ]); + }); + + interface ReportCase { + name: string; + send: (stream: MockMetadataStream) => void; + reported: AgentMetadataState; + } + + it.each([ + { + name: "metadata", + send: (stream) => stream.pushMessage({ data: [agentMetadata()] }), + reported: REPORTED_METADATA, + }, + { + name: "a failure", + send: (stream) => stream.pushError(new Error("boom")), + reported: failed("boom"), + }, + ])("reports $name an agent sends", async ({ send, reported }) => { + const { tracker, stream } = await setup("agent-1"); + send(stream("agent-1")); + expect(tracker.metadata).toEqual({ "agent-1": reported }); + }); + + it("keeps a released socket for a while, reporting only what is watched", async () => { + const { tracker, reports, stream } = await setup("agent-1", "agent-2"); + const released = stream("agent-1"); + await tracker.watch(["agent-2"]); + expect(reports.at(-1)).toEqual({ "agent-2": PENDING_METADATA }); + expect(released.close).not.toHaveBeenCalled(); + + const reported = reports.length; + released.pushMessage({ data: [agentMetadata()] }); + expect(reports).toHaveLength(reported); + + vi.advanceTimersByTime(LINGER_MS); + expect(released.close).toHaveBeenCalled(); + }); + + it("reuses a lingering socket, with what it already reported", async () => { + const { opened, tracker, stream } = await setup("agent-1"); + stream("agent-1").pushMessage({ data: [agentMetadata()] }); + for (let i = 0; i < 5; i++) { + await tracker.watch([]); + vi.advanceTimersByTime(LINGER_MS / 2); + await tracker.watch(["agent-1"]); + } + expect(opened).toHaveBeenCalledTimes(1); + expect(tracker.metadata).toEqual({ "agent-1": REPORTED_METADATA }); + }); + + it("reports a socket that never opened, and retries it", async () => { + const { client, opened, tracker } = await setup(); + opened.mockRejectedValueOnce(new Error("socket refused")); + await tracker.watch(["agent-1"]); + expect(tracker.metadata).toEqual({ "agent-1": failed("socket refused") }); + + await tracker.watch(["agent-1"]); + expect(client.metadataStreams.has("agent-1")).toBe(true); + }); + + it("reopens a socket that died, ignoring what the old one still says", async () => { + const { opened, tracker, stream } = await setup("agent-1"); + const old = stream("agent-1"); + old.emit("close", died); + old.close.mockImplementation(() => old.pushError(new Error("closing"))); + await tracker.watch(["agent-1"]); + expect(opened).toHaveBeenCalledTimes(2); + + stream("agent-1").pushMessage({ data: [agentMetadata()] }); + old.pushError(new Error("stale")); + expect(tracker.metadata).toEqual({ "agent-1": REPORTED_METADATA }); + }); + + it("clears every socket at once, and closes for good once disposed", async () => { + const { client, tracker, reports, opened } = await setup( + "agent-1", + "agent-2", + ); + await tracker.watch(["agent-1"]); + tracker.clear(); + for (const socket of client.metadataStreams.values()) { + expect(socket.close).toHaveBeenCalledOnce(); + } + expect(reports.at(-1)).toEqual({}); + await tracker.watch(["agent-1"]); + expect(opened).toHaveBeenCalledTimes(3); + + const reported = reports.length; + tracker.dispose(); + client.metadataStream("agent-1").pushMessage({ data: [agentMetadata()] }); + await tracker.watch(["agent-3"]); + expect(reports).toHaveLength(reported); + expect(opened).toHaveBeenCalledTimes(3); + }); + + describe("races", () => { + it("joins overlapping opens for the same agent", async () => { + const { tracker, opened } = await setup(); + const pending = Promise.withResolvers(); + opened.mockReturnValueOnce(pending.promise); + const first = tracker.watch(["agent-1"]); + const second = tracker.watch(["agent-1"]); + expect(opened).toHaveBeenCalledOnce(); + pending.resolve(new MockEventStream()); + await Promise.all([first, second]); + }); + + interface StaleOpenCase { + expiry: string; + expire: (tracker: AgentMetadataTracker) => void | Promise; + outcome: "resolves" | "rejects"; + } + + const expiries: Record = { + "lingered out": async (tracker) => { + await tracker.watch([]); + vi.advanceTimersByTime(LINGER_MS); + }, + "was cleared": (tracker) => tracker.clear(), + "was disposed": (tracker) => tracker.dispose(), + }; + + it.each( + Object.entries(expiries).flatMap(([expiry, expire]) => [ + { expiry, expire, outcome: "resolves" }, + { expiry, expire, outcome: "rejects" }, + ]), + )( + "drops the open of an agent that $expiry when it $outcome", + async ({ expire, outcome }) => { + const { tracker, opened } = await setup(); + const pending = Promise.withResolvers(); + opened.mockReturnValueOnce(pending.promise); + const stale = tracker.watch(["agent-1"]); + await expire(tracker); + + const old: MockMetadataStream = new MockEventStream(); + if (outcome === "resolves") pending.resolve(old); + else pending.reject(new Error("stale error")); + await stale; + + expect(old.close).toHaveBeenCalledTimes(outcome === "resolves" ? 1 : 0); + expect(tracker.metadata).toEqual({}); + }, + ); + + it("replays data received before the tracker subscribes", async () => { + const { tracker, opened } = await setup(); + const socket: MockMetadataStream = new MockEventStream(); + const add = socket.addEventListener.bind(socket); + vi.spyOn(socket, "addEventListener").mockImplementation( + (event, listener) => { + add(event, listener); + if (event === "message") + socket.pushMessage({ data: [agentMetadata()] }); + }, + ); + opened.mockResolvedValueOnce(socket); + await tracker.watch(["agent-1"]); + expect(tracker.metadata).toEqual({ "agent-1": REPORTED_METADATA }); + }); + }); +}); diff --git a/test/unit/workspace/filters.test.ts b/test/unit/workspace/filters.test.ts new file mode 100644 index 0000000000..8c2f5088db --- /dev/null +++ b/test/unit/workspace/filters.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { + availableFilters, + isQueryRejected, + WORKSPACE_FILTERS, +} from "@/workspace/filters"; + +import { + createAxiosError, + signedInSession, + userWithRoles, +} from "../../mocks/testHelpers"; + +import type { SessionData } from "@/deployment/sessionStore"; + +import type { WorkspaceFilter } from "@repo/shared"; + +const MEMBER = signedInSession(); +const OWNER = signedInSession(userWithRoles("owner")); +const SIGNED_OUT: SessionData = { kind: "signedOut", deployment: null }; + +describe("WORKSPACE_FILTERS", () => { + interface QueryCase { + filter: WorkspaceFilter; + query: string; + } + + it.each([ + { filter: "mine", query: "owner:me" }, + { filter: "shared", query: `shared_with_user:${MEMBER.user.id}` }, + { filter: "all", query: "" }, + ])("queries $filter workspaces", ({ filter, query }) => { + expect(WORKSPACE_FILTERS[filter].getQuery(MEMBER)).toBe(query); + }); +}); + +describe("availableFilters", () => { + interface OfferCase { + name: string; + session: SessionData; + unsupported?: WorkspaceFilter[]; + offered: WorkspaceFilter[]; + } + + it.each([ + { name: "signed out", session: SIGNED_OUT, offered: [] }, + { name: "a member", session: MEMBER, offered: ["mine", "shared"] }, + { name: "an owner", session: OWNER, offered: ["mine", "shared", "all"] }, + { + name: "an owner whose deployment rejected one", + session: OWNER, + unsupported: ["shared"], + offered: ["mine", "all"], + }, + ])("offers $name $offered", ({ session, unsupported, offered }) => { + expect(availableFilters(session, new Set(unsupported))).toEqual(offered); + }); +}); + +describe("isQueryRejected", () => { + it("recognizes only a 400 from the deployment", () => { + expect(isQueryRejected(createAxiosError(400, "invalid query"))).toBe(true); + expect(isQueryRejected(createAxiosError(500, "down"))).toBe(false); + expect(isQueryRejected(new Error("offline"))).toBe(false); + }); +}); diff --git a/test/unit/workspace/workspacesProvider.test.ts b/test/unit/workspace/workspacesProvider.test.ts index d0de4e95f1..62e1019651 100644 --- a/test/unit/workspace/workspacesProvider.test.ts +++ b/test/unit/workspace/workspacesProvider.test.ts @@ -3,7 +3,6 @@ import * as vscode from "vscode"; import { WorkspaceProvider, - WorkspaceQuery, type AgentTreeItem, type WorkspaceTreeItem, } from "@/workspace/workspacesProvider"; @@ -30,13 +29,15 @@ import type { import type { AgentMetadataEvent } from "@/api/api-helper"; import type { CoderApi } from "@/api/coderApi"; +import type { WorkspaceFilter } from "@repo/shared"; + function setup() { const logger = createMockLogger(); const client = new MockWorkspacesClient(); const session = new TestSessionStore(); const makeProvider = ( - query: WorkspaceQuery, - options?: { refreshIntervalMs?: number; onQueryRejected?: () => void }, + query: WorkspaceFilter, + options?: { onQueryRejected?: () => void }, ): WorkspaceProvider => new WorkspaceProvider( query, @@ -128,7 +129,7 @@ describe("WorkspaceProvider", () => { it("does not fetch while signed out", async () => { const { client, session, makeProvider } = setup(); session.signOut(); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); await show(provider); @@ -136,10 +137,10 @@ describe("WorkspaceProvider", () => { expect(await provider.getChildren()).toEqual([]); }); - it.each([ - [WorkspaceQuery.Mine, "owner:me"], - [WorkspaceQuery.Shared, `shared_with_user:${TEST_CURRENT_USER_ID}`], - [WorkspaceQuery.All, ""], + it.each<[WorkspaceFilter, string]>([ + ["mine", "owner:me"], + ["shared", `shared_with_user:${TEST_CURRENT_USER_ID}`], + ["all", ""], ])("fetches %s with the expected query", async (query, expectedQuery) => { const { client, makeProvider } = setup(); const provider = makeProvider(query); @@ -149,19 +150,23 @@ describe("WorkspaceProvider", () => { expect(client.getWorkspaces).toHaveBeenCalledWith({ q: expectedQuery }); }); - it.each([ + it.each<{ + query: WorkspaceFilter; + label: string; + collapsibleState: vscode.TreeItemCollapsibleState; + }>([ { - query: WorkspaceQuery.Mine, + query: "mine", label: "dev", collapsibleState: vscode.TreeItemCollapsibleState.Expanded, }, { - query: WorkspaceQuery.Shared, + query: "shared", label: "alice / dev", collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, }, { - query: WorkspaceQuery.All, + query: "all", label: "alice / dev", collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, }, @@ -198,7 +203,7 @@ describe("WorkspaceProvider", () => { response: { status: 400 }, }), ); - const provider = makeProvider(WorkspaceQuery.Shared, { onQueryRejected }); + const provider = makeProvider("shared", { onQueryRejected }); await show(provider); @@ -210,7 +215,7 @@ describe("WorkspaceProvider", () => { const { client, makeProvider } = setup(); const onQueryRejected = vi.fn(); client.getWorkspaces.mockRejectedValueOnce(new Error("network down")); - const provider = makeProvider(WorkspaceQuery.Shared, { onQueryRejected }); + const provider = makeProvider("shared", { onQueryRejected }); await show(provider); @@ -220,7 +225,7 @@ describe("WorkspaceProvider", () => { it("clears rendered workspaces when the session signs out", async () => { const { client, session, makeProvider } = setup(); client.respondOnce([workspace({ name: "dev" })]); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); await show(provider); expect(await labels(provider)).toEqual(["dev"]); @@ -234,7 +239,7 @@ describe("WorkspaceProvider", () => { it("does not render a pending response after sign-out", async () => { const { client, session, makeProvider } = setup(); const pending = client.pending(); - const provider = makeProvider(WorkspaceQuery.Shared); + const provider = makeProvider("shared"); provider.setVisibility(true); await flush(); @@ -251,7 +256,7 @@ describe("WorkspaceProvider", () => { client.respondOnce([ workspace({ owner_id: "alice-id", owner_name: "alice", name: "fresh" }), ]); - const provider = makeProvider(WorkspaceQuery.Shared); + const provider = makeProvider("shared"); provider.setVisibility(true); await flush(); @@ -267,7 +272,7 @@ describe("WorkspaceProvider", () => { it("does not fetch while hidden", async () => { const { client, makeProvider } = setup(); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); await provider.fetchAndRefresh(); @@ -277,7 +282,7 @@ describe("WorkspaceProvider", () => { it("renders a response that completes after the tree is hidden", async () => { const { client, makeProvider } = setup(); const pending = client.pending(); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); provider.setVisibility(true); await flush(); @@ -292,7 +297,7 @@ describe("WorkspaceProvider", () => { const { client, session, makeProvider } = setup(); const pending = client.pending(); client.respondOnce([workspace({ name: "fresh" })]); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); provider.setVisibility(true); await flush(); @@ -311,7 +316,7 @@ describe("WorkspaceProvider", () => { const { client, makeProvider } = setup(); client.respondOnce([workspace({ name: "dev" })]); client.getWorkspaces.mockRejectedValueOnce(new Error("network down")); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); await show(provider); expect(await labels(provider)).toEqual(["dev"]); @@ -327,9 +332,7 @@ describe("WorkspaceProvider", () => { const { client, makeProvider } = setup(); client.respondOnce([workspace({ name: "first" })]); client.respondOnce([workspace({ name: "second" })]); - const provider = makeProvider(WorkspaceQuery.Mine, { - refreshIntervalMs: 5_000, - }); + const provider = makeProvider("mine"); provider.setVisibility(true); await flushPromises(); @@ -352,7 +355,7 @@ describe("WorkspaceProvider", () => { agent({ id: "agent-2", name: "sidecar", status: "disconnected" }), ]), ]); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); await show(provider); const [workspaceItem] = @@ -386,7 +389,7 @@ describe("WorkspaceProvider", () => { }), ]), ]); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); await show(provider); const [workspaceItem] = @@ -414,7 +417,7 @@ describe("WorkspaceProvider", () => { agent({ id: "agent-1", name: "main" }), ]), ]); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); await show(provider); const [workspaceItem] = @@ -440,7 +443,7 @@ describe("WorkspaceProvider", () => { client.respondOnce([ workspaceWithAgents({ name: "dev" }, [agent({ id: "agent-1" })]), ]); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); await show(provider); expect(await labels(provider)).toEqual(["dev"]); @@ -454,7 +457,7 @@ describe("WorkspaceProvider", () => { const { client, session, makeProvider } = setup(); session.signOut(); client.respondOnce([workspace({ name: "dev" })]); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); await show(provider); expect(client.getWorkspaces).not.toHaveBeenCalled(); @@ -468,7 +471,7 @@ describe("WorkspaceProvider", () => { it("stops reacting to session changes after dispose", async () => { const { client, session, makeProvider } = setup(); client.respondOnce([workspace({ name: "dev" })]); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); await show(provider); expect(await labels(provider)).toEqual(["dev"]); @@ -485,7 +488,7 @@ describe("WorkspaceProvider", () => { const { client, session, makeProvider } = setup(); const pending = client.pending(); client.getWorkspaces.mockRejectedValueOnce(new Error("network down")); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); provider.setVisibility(true); await flush(); @@ -508,7 +511,7 @@ describe("WorkspaceProvider", () => { const first = client.pending(); const second = client.pending(); client.respondOnce([workspace({ name: "fresh" })]); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); provider.setVisibility(true); await flush(); @@ -541,7 +544,7 @@ describe("WorkspaceProvider", () => { const watch = Promise.withResolvers>(); client.watchAgentMetadata = vi.fn((_agentId: string) => watch.promise); - const provider = makeProvider(WorkspaceQuery.Mine); + const provider = makeProvider("mine"); provider.setVisibility(true); await flush(); diff --git a/test/webview/workspaces/useWorkspaces.test.tsx b/test/webview/workspaces/useWorkspaces.test.tsx new file mode 100644 index 0000000000..cb783d70e5 --- /dev/null +++ b/test/webview/workspaces/useWorkspaces.test.tsx @@ -0,0 +1,67 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { WorkspacesApi, type WorkspacesState } from "@repo/shared"; +import { useWorkspaces } from "@repo/workspaces/hooks/useWorkspaces"; + +// The webview may acquire the VS Code API once per page, so one stub serves +// every test and forwards to the sink the current test installed. +let sink: unknown[] = []; +vi.stubGlobal("acquireVsCodeApi", () => ({ + postMessage: (message: unknown) => sink.push(message), + getState: () => undefined, + setState: () => {}, +})); + +const STATE: WorkspacesState = { + capabilities: { authenticated: true, filters: ["mine"] }, + filter: "mine", + workspaces: [], + status: { kind: "ready" }, + metadata: {}, +}; + +function setup() { + const sent: unknown[] = []; + sink = sent; + return { sent, ...renderHook(() => useWorkspaces()) }; +} + +/** Push the state the way the extension does. */ +const push = (state: WorkspacesState) => + act(async () => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: WorkspacesApi.stateChanged.method, data: state }, + }), + ); + await Promise.resolve(); + }); + +describe("useWorkspaces", () => { + it("asks for the state, then replaces its own with each push", async () => { + const { sent, result } = setup(); + expect(sent).toEqual([ + { method: WorkspacesApi.ready.method, params: undefined }, + ]); + expect(result.current.state).toBeUndefined(); + + await push(STATE); + expect(result.current.state).toEqual(STATE); + + const failed: WorkspacesState = { + ...STATE, + status: { kind: "failed", error: "down" }, + }; + await push(failed); + expect(result.current.state).toEqual(failed); + }); + + it("stops applying pushes once unmounted", async () => { + const { result, unmount } = setup(); + await push(STATE); + unmount(); + await push({ ...STATE, filter: "shared" }); + expect(result.current.state).toEqual(STATE); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 3cc835357f..88ec0b90d9 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -51,6 +51,10 @@ export default defineConfig({ "packages/tasks/src", ), "@repo/ui": path.resolve(import.meta.dirname, "packages/ui/src"), + "@repo/workspaces": path.resolve( + import.meta.dirname, + "packages/workspaces/src", + ), "@repo/netcheck": path.resolve( import.meta.dirname, "packages/netcheck/src",