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",