From 5ae7bef19c503234581262d70ff924800dfc7256 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 26 Aug 2026 23:27:11 +0300 Subject: [PATCH 1/2] feat(workspaces): IPC API and extension-side data layer for the panel Adds the typed IPC contract for the experimental Workspaces panel and the extension-side provider that owns its data, porting the tree views' behaviors to push through IPC. `packages/shared/src/workspaces` defines the contract: `stateUpdated` out; `ready`, `openWorkspace`, `viewInDashboard`, `refresh`, `setFilter` and `watchAgents` back. State is pushed as one update carrying only the fields that changed, and the payloads carry decisions rather than facts to derive, so the webview holds no data and applies no policy: it asks for the state with `ready` and renders what arrives. `WorkspaceStore` lists the active filter while visible, backs off on failures, watches metadata for the agents the panel is showing, and reports what changed. A cancellation token per fetch drops superseded results, the list is pushed before sockets open, and a structural diff keeps quiet polls off the wire. Filters that a deployment rejects stop being offered. Split by concern, in their own layers: - `src/workspace/agentMetadataTracker.ts`: the watched set and its sockets, which linger briefly after release so toggling a row reuses them - `src/workspace/filters.ts`: each filter's query, presentation, role requirement and poll policy, shared with the tree views instead of duplicated `isOwner(user)` moved to `src/api/api-helper.ts` for both `deploymentManager` and the panel, since the `coder.isOwner` context is written after the session change fires. Existing tree views are untouched. The webview's placeholder App prints the pushed state; the UI lands with the tree components. --- packages/mocks/src/workspaces.ts | 41 +- packages/shared/src/index.ts | 3 +- packages/shared/src/workspaces/api.ts | 32 +- packages/shared/src/workspaces/types.ts | 69 +++ packages/workspaces/src/App.tsx | 7 +- .../workspaces/src/hooks/useWorkspaces.ts | 26 ++ src/api/agentMetadataHelper.ts | 3 + src/api/api-helper.ts | 6 + src/deployment/deploymentManager.ts | 4 +- src/extension.ts | 23 +- src/webviews/workspaces/panelProvider.ts | 223 +++++++++ src/webviews/workspaces/store.ts | 328 +++++++++++++ .../workspaces/workspacesPanelProvider.ts | 102 ---- src/workspace/agentMetadataTracker.ts | 191 ++++++++ src/workspace/filters.ts | 63 +++ src/workspace/workspacesProvider.ts | 35 +- test/mocks/testHelpers.ts | 8 +- test/mocks/vscode.runtime.ts | 23 + test/tsconfig.json | 3 +- test/unit/api/api-helper.test.ts | 27 ++ test/unit/webviews/workspaces/harness.ts | 172 +++++++ .../webviews/workspaces/panelProvider.test.ts | 261 +++++++++++ test/unit/webviews/workspaces/store.test.ts | 441 ++++++++++++++++++ .../workspace/agentMetadataTracker.test.ts | 226 +++++++++ test/unit/workspace/filters.test.ts | 69 +++ .../webview/workspaces/useWorkspaces.test.tsx | 75 +++ vitest.config.mts | 4 + 27 files changed, 2316 insertions(+), 149 deletions(-) create mode 100644 packages/shared/src/workspaces/types.ts create mode 100644 packages/workspaces/src/hooks/useWorkspaces.ts create mode 100644 src/webviews/workspaces/panelProvider.ts create mode 100644 src/webviews/workspaces/store.ts delete mode 100644 src/webviews/workspaces/workspacesPanelProvider.ts create mode 100644 src/workspace/agentMetadataTracker.ts create mode 100644 src/workspace/filters.ts create mode 100644 test/unit/api/api-helper.test.ts create mode 100644 test/unit/webviews/workspaces/harness.ts create mode 100644 test/unit/webviews/workspaces/panelProvider.test.ts create mode 100644 test/unit/webviews/workspaces/store.test.ts create mode 100644 test/unit/workspace/agentMetadataTracker.test.ts create mode 100644 test/unit/workspace/filters.test.ts create mode 100644 test/webview/workspaces/useWorkspaces.test.tsx diff --git a/packages/mocks/src/workspaces.ts b/packages/mocks/src/workspaces.ts index 13378b1968..df95ad71bc 100644 --- a/packages/mocks/src/workspaces.ts +++ b/packages/mocks/src/workspaces.ts @@ -5,6 +5,7 @@ import type { Workspace, WorkspaceAgent, + WorkspaceAgentMetadata, WorkspaceBuild, WorkspaceResource, } from "coder/site/src/api/typesGenerated"; @@ -51,13 +52,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 +84,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 +135,32 @@ 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, + }, + }; +} + /** 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..9cb098f029 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, + WorkspacesUpdate, +} from "./types"; + +export const WorkspacesApi = { + // Notifications + /** Every field of the state that changed, applied together */ + stateUpdated: defineNotification("stateUpdated"), + // Commands + /** Webview signals its subscription is live and asks for the whole 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..8d7339ebd9 --- /dev/null +++ b/packages/shared/src/workspaces/types.ts @@ -0,0 +1,69 @@ +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[]; +} + +export interface FilteredWorkspaces { + readonly filter: WorkspaceFilter; + readonly workspaces: readonly Workspace[]; + /** True while the first list for this filter is still on its way. */ + readonly loading: boolean; +} + +export interface AgentMetadataState { + readonly metadata: readonly WorkspaceAgentMetadata[]; + /** The watcher failure, which replaces the metadata in the UI. */ + readonly error: string | null; + /** True until the agent reports for the first time. */ + readonly loading: boolean; +} + +/** Keyed by agent id. */ +export type AgentMetadataMap = Readonly>; + +/** Everything the panel renders. Fields are replaced, never mutated. */ +export interface WorkspacesState { + readonly capabilities: WorkspacesCapabilities; + readonly workspaces: FilteredWorkspaces; + readonly metadata: AgentMetadataMap; + readonly error: string | null; +} + +/** A state slice: present fields changed, absent ones did not. */ +export type WorkspacesUpdate = Partial; + +export interface OpenWorkspaceParams { + workspaceId: string; + /** Which agent to connect to. Picked interactively when omitted. */ + agentId?: string; +} + +export interface ViewInDashboardParams { + workspaceId: string; + page: DashboardPage; +} + +export interface SetFilterParams { + filter: WorkspaceFilter; +} + +export interface WatchAgentsParams { + /** The agents whose metadata the webview is showing. */ + agentIds: readonly string[]; +} diff --git a/packages/workspaces/src/App.tsx b/packages/workspaces/src/App.tsx index abed211177..d52c052b27 100644 --- a/packages/workspaces/src/App.tsx +++ b/packages/workspaces/src/App.tsx @@ -1,3 +1,8 @@ +import { useWorkspaces } from "./hooks/useWorkspaces"; + +/** Placeholder: renders the pushed state until the panel UI lands. */ export default function App() { - return
TODO
; + const { state } = useWorkspaces(); + + 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..85d43f5923 --- /dev/null +++ b/packages/workspaces/src/hooks/useWorkspaces.ts @@ -0,0 +1,26 @@ +import { + buildApiHook, + WorkspacesApi, + type WorkspacesUpdate, +} 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. State fields + * are undefined until their first push, which `ready` asks for. + */ +export function useWorkspaces() { + const api = buildApiHook(WorkspacesApi, useIpc()); + const [state, setState] = useState({}); + + useEffect(() => { + const unsubscribe = api.onStateUpdated((update) => + setState((previous) => ({ ...previous, ...update })), + ); + api.ready(); + return unsubscribe; + }, []); + + return { state, api }; +} diff --git a/src/api/agentMetadataHelper.ts b/src/api/agentMetadataHelper.ts index dfdf7d41f2..d34b6e9d93 100644 --- a/src/api/agentMetadataHelper.ts +++ b/src/api/agentMetadataHelper.ts @@ -13,6 +13,8 @@ export interface AgentMetadataWatcher { dispose: () => void; metadata?: AgentMetadataEvent[]; error?: unknown; + /** True once the socket closed on its own, so it reports nothing more. */ + closed?: boolean; } /** @@ -70,6 +72,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/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/extension.ts b/src/extension.ts index e5733b0bb2..6f6e3ce290 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -27,7 +27,8 @@ 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 { WorkspacesPanelProvider } from "./webviews/workspaces/panelProvider"; +import { WorkspaceStore } from "./webviews/workspaces/store"; import { WorkspaceProvider, WorkspaceQuery, @@ -302,12 +303,28 @@ async function doActivate( contextManager.set("coder.workspacesPanelEnabled", workspacesPanelEnabled); if (workspacesPanelEnabled) { - const workspacesPanelProvider = new WorkspacesPanelProvider( - ctx.extensionUri, + const workspacesStore = new WorkspaceStore( + client, output, + deploymentManager.session, ); + const workspacesPanelProvider = new WorkspacesPanelProvider({ + extensionUri: ctx.extensionUri, + client, + logger: output, + store: workspacesStore, + openWorkspace: (workspace, agent) => + commands.open({ + workspaceOwner: workspace.owner_name, + workspaceName: workspace.name, + agentName: agent?.name, + openRecent: true, + source: agent ? "sidebar_agent" : "sidebar_workspace", + }), + }); ctx.subscriptions.push( + workspacesStore, workspacesPanelProvider, vscode.window.registerWebviewViewProvider( WorkspacesPanelProvider.viewType, diff --git a/src/webviews/workspaces/panelProvider.ts b/src/webviews/workspaces/panelProvider.ts new file mode 100644 index 0000000000..e329ecab04 --- /dev/null +++ b/src/webviews/workspaces/panelProvider.ts @@ -0,0 +1,223 @@ +import * as vscode from "vscode"; + +import { + buildCommandHandlers, + buildRequestHandlers, + WorkspacesApi, + type DashboardPage, + type OpenWorkspaceParams, + type ViewInDashboardParams, + type WorkspacesUpdate, +} from "@repo/shared"; + +import { createWorkspaceIdentifier, extractAgents } from "../../api/api-helper"; +import { openInBrowser } from "../../util/uri"; +import { + dispatchCommand, + dispatchRequest, + isIpcCommand, + isIpcRequest, + notifyWebview, +} from "../dispatch"; +import { getWebviewHtml } from "../html"; + +import type { + Workspace, + WorkspaceAgent, +} from "coder/site/src/api/typesGenerated"; + +import type { CoderApi } from "../../api/coderApi"; +import type { Logger } from "../../logging/logger"; + +import type { WorkspaceStore } from "./store"; + +/** Methods whose failures warrant a dialog; others are logged only. */ +const USER_ACTION_METHODS: ReadonlySet = new Set([ + WorkspacesApi.openWorkspace.method, + WorkspacesApi.viewInDashboard.method, +]); + +/** Path of each dashboard page, relative to the workspace. */ +const DASHBOARD_PAGE_PATHS = { + workspace: "", + settings: "/settings", +} as const satisfies Record; + +export interface WorkspacesPanelOptions { + readonly extensionUri: vscode.Uri; + readonly client: CoderApi; + readonly logger: Logger; + readonly store: WorkspaceStore; + /** Connect to the workspace, or to one of its agents. */ + readonly openWorkspace: ( + workspace: Workspace, + agent: WorkspaceAgent | undefined, + ) => Promise; +} + +/** + * Renders the workspaces of the current deployment in a webview. All state + * lives in the extension: the store reports what changed, this pushes it, and a + * webview that just loaded asks for the whole state with `ready`. + */ +export class WorkspacesPanelProvider + implements vscode.WebviewViewProvider, vscode.Disposable +{ + public static readonly viewType = "coder.workspacesPanel"; + + private readonly extensionUri: vscode.Uri; + private readonly client: CoderApi; + private readonly logger: Logger; + private readonly store: WorkspaceStore; + private readonly openWorkspace: WorkspacesPanelOptions["openWorkspace"]; + + private readonly requestHandlers = buildRequestHandlers(WorkspacesApi, {}); + private readonly commandHandlers = buildCommandHandlers(WorkspacesApi, { + ready: () => this.push(this.store.state), + refresh: () => this.store.refresh(), + setFilter: (p) => this.store.setFilter(p.filter), + watchAgents: (p) => this.store.setWatchedAgents(p.agentIds), + openWorkspace: (p) => this.handleOpenWorkspace(p), + viewInDashboard: (p) => this.handleViewInDashboard(p), + }); + + private view: vscode.WebviewView | undefined; + private disposables: vscode.Disposable[] = []; + + constructor(options: WorkspacesPanelOptions) { + this.extensionUri = options.extensionUri; + this.client = options.client; + this.logger = options.logger; + this.store = options.store; + this.openWorkspace = options.openWorkspace; + } + + public refresh(): void { + this.store.refresh().catch((err: unknown) => { + this.logger.error("Failed to refresh workspaces", err); + }); + } + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + token: vscode.CancellationToken, + ): void { + if (token.isCancellationRequested) { + return; + } + this.view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath( + this.extensionUri, + "dist", + "webviews", + "workspaces", + ), + ], + }; + + this.disposeView(); + + this.disposables.push( + this.store.onDidChange((update) => this.push(update)), + // Only listing follows visibility; the view keeps its context. + webviewView.onDidChangeVisibility(() => + this.store.setVisible(webviewView.visible), + ), + 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()); + + void this.store.setVisible(webviewView.visible); + } + + dispose(): void { + this.disposeView(); + } + + private async handleMessage(message: unknown): Promise { + const showErrorToUser = (method: string) => 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, { + logger: this.logger, + showErrorToUser, + }); + } else { + this.logger.warn("Unexpected webview message", message); + } + } + + private push(update: WorkspacesUpdate): void { + const webview = this.view?.webview; + if (webview) { + notifyWebview(webview, WorkspacesApi.stateUpdated, update); + } + } + + private async handleOpenWorkspace({ + workspaceId, + agentId, + }: OpenWorkspaceParams): Promise { + const workspace = this.requireWorkspace(workspaceId); + const agent = agentId + ? extractAgents(workspace.latest_build.resources).find( + (candidate) => candidate.id === agentId, + ) + : undefined; + if (agentId && !agent) { + throw new Error("Agent is no longer available"); + } + await this.openWorkspace(workspace, agent); + } + + private async handleViewInDashboard({ + workspaceId, + page, + }: ViewInDashboardParams): Promise { + const workspace = this.requireWorkspace(workspaceId); + const connectionUrl = this.client.getHost(); + if (!connectionUrl) { + return; + } + await openInBrowser( + connectionUrl, + `/@${createWorkspaceIdentifier(workspace)}${DASHBOARD_PAGE_PATHS[page]}`, + ); + } + + private requireWorkspace(workspaceId: string): Workspace { + const workspace = this.store.findWorkspace(workspaceId); + if (!workspace) { + throw new Error("Workspace is no longer available"); + } + return workspace; + } + + private disposeView(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + } +} diff --git a/src/webviews/workspaces/store.ts b/src/webviews/workspaces/store.ts new file mode 100644 index 0000000000..2cdeb0eabf --- /dev/null +++ b/src/webviews/workspaces/store.ts @@ -0,0 +1,328 @@ +import { isAxiosError } from "axios"; +import { isDeepStrictEqual } from "node:util"; +import * as vscode from "vscode"; + +import { errToStr, extractAllAgents } from "../../api/api-helper"; +import { AgentMetadataTracker } from "../../workspace/agentMetadataTracker"; +import { + availableFilters, + DEFAULT_WORKSPACE_FILTER, + WORKSPACE_FILTERS, +} from "../../workspace/filters"; + +import type { + Workspace, + WorkspaceFilter, + WorkspacesState, + WorkspacesUpdate, +} from "@repo/shared"; + +import type { CoderApi } from "../../api/coderApi"; +import type { SessionState } from "../../deployment/sessionStore"; +import type { Logger } from "../../logging/logger"; + +export interface PollOptions { + /** Delay between polls of a filter that keeps polling. */ + readonly intervalMs: number; + /** Ceiling for the backed-off delay between retries. */ + readonly maxIntervalMs: number; +} + +const DEFAULT_POLL: PollOptions = { intervalMs: 5_000, maxIntervalMs: 60_000 }; + +/** What a fetch found, or how it failed. */ +interface Listed { + workspaces?: readonly Workspace[]; + loading?: boolean; + error?: string | null; +} + +/** + * Owns the state the Workspaces panel renders: lists the active filter while + * visible, watches the agents the panel is showing, and reports what changed. + */ +export class WorkspaceStore implements vscode.Disposable { + private readonly changeEmitter = new vscode.EventEmitter(); + private readonly agents: AgentMetadataTracker; + private readonly poll: PollOptions; + private readonly disposables: 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 loading = false; + private error: string | null = null; + /** Agents the webview is showing, listed or not. */ + private requestedAgents: readonly string[] = []; + /** The state as the webview has it, so nothing is pushed twice. */ + private pushed: WorkspacesState | undefined; + private fetching: vscode.CancellationTokenSource | undefined; + private fetch = Promise.resolve(); + private nextPoll: NodeJS.Timeout | undefined; + private retries = 0; + private visible = false; + private disposed = false; + + constructor( + private readonly client: CoderApi, + private readonly logger: Logger, + private readonly sessionState: SessionState, + poll: Partial = {}, + ) { + this.poll = { ...DEFAULT_POLL, ...poll }; + this.agents = new AgentMetadataTracker(client); + this.disposables = [ + this.agents, + this.agents.onDidChange(() => this.update({})), + 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), + }, + workspaces: { + filter: this.filter, + workspaces: this.workspaces, + loading: this.loading, + }, + metadata: this.agents.metadata, + error: this.error, + }; + } + + /** Resolves when the fetch in flight finishes. */ + public get settled(): Promise { + return this.fetch; + } + + public findWorkspace(workspaceId: string): Workspace | undefined { + return this.workspaces.find((workspace) => workspace.id === workspaceId); + } + + /** Start or stop listing. Fetches immediately when becoming visible. */ + public setVisible(visible: boolean): Promise { + if (this.disposed || this.visible === visible) { + return this.fetch; + } + this.visible = visible; + if (!visible) { + // Nothing renders while hidden: a short hide reuses the sockets. + this.cancelPoll(); + void this.agents.setWatched([]); + return this.fetch; + } + return this.startLoad(); + } + + /** Switch the list, ignoring filters that are not offered. */ + public setFilter(filter: WorkspaceFilter): Promise { + if (this.disposed || filter === this.filter) { + return this.fetch; + } + if (!this.state.capabilities.filters.includes(filter)) { + this.logger.warn(`Ignoring unavailable workspaces filter: ${filter}`); + return this.fetch; + } + this.filter = filter; + this.clearList(); + return this.startLoad(); + } + + /** Watch metadata for these agents only, dropping the rest. */ + public setWatchedAgents(agentIds: readonly string[]): Promise { + if (this.disposed) { + return Promise.resolve(); + } + this.requestedAgents = agentIds; + return this.watchListedAgents(); + } + + /** Fetch now, offering rejected filters again in case they work now. */ + public refresh(): Promise { + if (this.disposed) { + return this.fetch; + } + this.unsupportedFilters.clear(); + // Asked for, so it is worth showing, unlike a poll. + return this.startLoad(true); + } + + public dispose(): void { + this.disposed = true; + this.cancelFetch(); + this.cancelPoll(); + for (const disposable of this.disposables) { + disposable.dispose(); + } + this.changeEmitter.dispose(); + } + + private startLoad(awaited = false): Promise { + this.fetch = this.load(awaited).catch((error: unknown) => { + this.logger.error("Unexpected failure while listing workspaces", error); + }); + return this.fetch; + } + + /** Fetch the active filter and push the result. Never rejects. */ + private async load(awaited = false): Promise { + if (this.disposed || !this.visible) { + return; + } + const token = this.startFetch(); + this.update({ loading: awaited || !this.delivered }); + + const session = this.sessionState.current; + if (session.kind !== "signedIn") { + this.update({ workspaces: [], loading: false }); + return; + } + + const { getQuery, poll } = WORKSPACE_FILTERS[this.filter]; + try { + const { workspaces } = await this.client.getWorkspaces({ + q: getQuery(session), + }); + if (token.isCancellationRequested) { + return; + } + // Push before opening sockets so the list is not held back by them. + this.update({ workspaces, loading: false, error: null }); + await this.watchListedAgents(); + if (poll && !token.isCancellationRequested) { + this.scheduleLoad(false); + } + } catch (error) { + if (token.isCancellationRequested) { + return; + } + if (this.isRejectedQuery(error)) { + await this.stopOfferingFilter(this.filter); + return; + } + this.logger.warn("Failed to fetch workspaces:", error); + this.update({ + workspaces: [], + loading: false, + error: errToStr(error, "Failed to fetch workspaces"), + }); + this.scheduleLoad(true); + } + } + + /** Record what changed, and push what the webview does not have yet. */ + private update(listed: Listed): void { + if (this.disposed) { + return; + } + this.workspaces = listed.workspaces ?? this.workspaces; + this.loading = listed.loading ?? this.loading; + this.error = listed.error === undefined ? this.error : listed.error; + + const state = this.state; + const fields = Object.keys(state) as Array; + const changed = fields.filter( + (field) => !isDeepStrictEqual(state[field], this.pushed?.[field]), + ); + this.pushed = state; + if (changed.length > 0) { + this.changeEmitter.fire( + Object.fromEntries(changed.map((field) => [field, state[field]])), + ); + } + } + + /** Whether the webview already has a settled list for the active filter. */ + private get delivered(): boolean { + const listed = this.pushed?.workspaces; + return listed?.filter === this.filter && !listed.loading; + } + + /** Drop the list of the previous filter or session. A fetch follows. */ + private clearList(): void { + this.update({ workspaces: [], loading: this.visible, error: null }); + } + + /** Supersede the fetch in flight, so its result is dropped. */ + private startFetch(): vscode.CancellationToken { + this.cancelFetch(); + this.cancelPoll(); + this.fetching = new vscode.CancellationTokenSource(); + return this.fetching.token; + } + + private cancelFetch(): void { + this.fetching?.cancel(); + this.fetching?.dispose(); + this.fetching = undefined; + } + + /** Queue the next load, backing off while fetches keep failing. */ + private scheduleLoad(failed: boolean): void { + this.cancelPoll(); + this.retries = failed ? this.retries + 1 : 0; + this.nextPoll = setTimeout( + () => void this.startLoad(), + Math.min( + this.poll.intervalMs * 2 ** this.retries, + this.poll.maxIntervalMs, + ), + ); + } + + private cancelPoll(): void { + clearTimeout(this.nextPoll); + this.nextPoll = undefined; + } + + /** Watch the agents the webview asked for that are still listed. */ + private watchListedAgents(): Promise { + const listed = new Set( + extractAllAgents(this.workspaces).map((agent) => agent.id), + ); + return this.agents + .setWatched(this.requestedAgents.filter((agentId) => listed.has(agentId))) + .catch((error: unknown) => { + // Metadata is supplementary: never report it as a failure to list. + this.logger.warn("Failed to watch agent metadata:", error); + }); + } + + /** The default filter is never dropped: its query is as old as the API. */ + private isRejectedQuery(error: unknown): boolean { + return ( + this.filter !== DEFAULT_WORKSPACE_FILTER && + isAxiosError(error) && + error.response?.status === 400 + ); + } + + /** Never leave a filter selected that cannot load. */ + private stopOfferingFilter(filter: WorkspaceFilter): Promise { + this.unsupportedFilters.add(filter); + this.filter = DEFAULT_WORKSPACE_FILTER; + this.clearList(); + return this.load(); + } + + private handleSessionChange(): void { + if (this.disposed) { + return; + } + this.unsupportedFilters.clear(); + this.requestedAgents = []; + if (!this.state.capabilities.filters.includes(this.filter)) { + this.filter = DEFAULT_WORKSPACE_FILTER; + } + this.clearList(); + void this.startLoad(); + } +} 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..d0288d735e --- /dev/null +++ b/src/workspace/agentMetadataTracker.ts @@ -0,0 +1,191 @@ +import * as vscode from "vscode"; + +import { + createAgentMetadataWatcher, + formatMetadataError, + type AgentMetadataWatcher, +} from "../api/agentMetadataHelper"; + +import type { + AgentMetadataMap, + AgentMetadataState, + WorkspaceAgent, +} from "@repo/shared"; + +import type { CoderApi } from "../api/coderApi"; + +type AgentId = WorkspaceAgent["id"]; + +/** Nothing reported yet: the socket is opening, or the agent is quiet. */ +const PENDING: AgentMetadataState = { + metadata: [], + error: null, + loading: true, +}; + +export interface AgentMetadataTrackerOptions { + /** How long a released socket stays open, in case it is wanted again. */ + readonly lingerMs: number; +} + +const DEFAULT_OPTIONS: AgentMetadataTrackerOptions = { lingerMs: 15_000 }; + +interface WatchedAgent { + /** Absent while the socket is opening, or after it failed to open. */ + watcher?: AgentMetadataWatcher; + state: AgentMetadataState; + /** Pending close, set for exactly as long as nothing is watching. */ + closing?: NodeJS.Timeout; +} + +/** A socket that never opened, or died since, needs opening again. */ +function needsSocket(watched: WatchedAgent | undefined): boolean { + if (!watched) { + return false; + } + return !watched.watcher || watched.watcher.closed === true; +} + +/** + * Watches agent metadata over SSE. The watched set is declared, not built up: + * `setWatched` opens the sockets it is missing, releases the rest, and reports + * only the agents currently watched. + */ +export class AgentMetadataTracker implements vscode.Disposable { + private readonly changeEmitter = new vscode.EventEmitter(); + private readonly watched = new Map(); + private readonly options: AgentMetadataTrackerOptions; + + public readonly onDidChange = this.changeEmitter.event; + + private disposed = false; + + constructor( + private readonly client: CoderApi, + options: Partial = {}, + ) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + } + + /** The latest report of every watched agent, keyed by agent id. */ + public get metadata(): AgentMetadataMap { + return Object.fromEntries( + [...this.watched] + .filter(([, watched]) => !watched.closing) + .map(([agentId, watched]) => [agentId, watched.state]), + ); + } + + /** + * Watch exactly `agentIds`. Sockets open in parallel and a failure is + * reported against its agent rather than thrown, so one bad socket neither + * blocks the rest nor stops the next call from retrying it. + */ + public async setWatched(agentIds: Iterable): Promise { + const wanted = new Set(agentIds); + let changed = false; + + for (const [agentId, watched] of this.watched) { + changed = wanted.has(agentId) + ? this.keep(watched) || changed + : this.release(agentId, watched) || changed; + } + for (const agentId of wanted) { + if (!this.watched.has(agentId)) { + this.watched.set(agentId, { state: PENDING }); + changed = true; + } + } + if (changed) { + this.fire(); + } + + const opening = [...wanted].filter((agentId) => + needsSocket(this.watched.get(agentId)), + ); + await Promise.all(opening.map((agentId) => this.open(agentId))); + } + + public dispose(): void { + for (const agentId of [...this.watched.keys()]) { + this.close(agentId); + } + this.disposed = true; + this.changeEmitter.dispose(); + } + + /** Take an agent back before its socket closes. */ + private keep(watched: WatchedAgent): boolean { + if (!watched.closing) { + return false; + } + clearTimeout(watched.closing); + watched.closing = undefined; + return true; + } + + /** Let an agent go, closing its socket unless it is wanted again in time. */ + private release(agentId: AgentId, watched: WatchedAgent): boolean { + if (watched.closing) { + return false; + } + watched.closing = setTimeout( + () => this.close(agentId), + this.options.lingerMs, + ); + return true; + } + + private async open(agentId: AgentId): Promise { + try { + const watcher = await createAgentMetadataWatcher(agentId, this.client); + const watched = this.watched.get(agentId); + // Disposal, a newer set, or a parallel open may have raced this socket. + if (this.disposed || !watched || !needsSocket(watched)) { + watcher.dispose(); + return; + } + watched.watcher?.dispose(); + watched.watcher = watcher; + watcher.onChange(() => this.report(agentId, watcher)); + } catch (error) { + this.report(agentId, { error }); + } + } + + /** Record what an agent reported, and push it if it is still watched. */ + private report( + agentId: AgentId, + watcher: Partial, + ): void { + const watched = this.watched.get(agentId); + if (this.disposed || !watched) { + return; + } + watched.state = { + metadata: watcher.metadata ?? [], + error: + watcher.error === undefined ? null : formatMetadataError(watcher.error), + loading: false, + }; + if (!watched.closing) { + this.fire(); + } + } + + private close(agentId: AgentId): void { + const watched = this.watched.get(agentId); + if (!watched) { + return; + } + clearTimeout(watched.closing); + watched.watcher?.dispose(); + this.watched.delete(agentId); + } + + private fire(): void { + if (!this.disposed) { + this.changeEmitter.fire(this.metadata); + } + } +} diff --git a/src/workspace/filters.ts b/src/workspace/filters.ts new file mode 100644 index 0000000000..baf10d1ae3 --- /dev/null +++ b/src/workspace/filters.ts @@ -0,0 +1,63 @@ +import { isOwner } from "../api/api-helper"; + +import type { WorkspaceFilter } from "@repo/shared"; + +import type { SessionData } from "../deployment/sessionStore"; + +type SignedInSession = Extract; + +/** How one set of workspaces is listed, rendered, and who may select it. */ +export interface WorkspaceFilterConfig { + readonly showOwner: boolean; + readonly showMetadata: boolean; + readonly requiresOwner: boolean; + /** Whether the panel keeps polling this filter, or fetches on demand. */ + readonly poll: boolean; + readonly getQuery: (session: SignedInSession) => string; +} + +export const WORKSPACE_FILTERS = { + mine: { + showOwner: false, + showMetadata: true, + requiresOwner: false, + poll: true, + getQuery: () => "owner:me", + }, + shared: { + showOwner: true, + showMetadata: false, + requiresOwner: false, + poll: false, + // Excludes workspaces the user owns and shared with others. + // Requires Coder 2.27.0+. + getQuery: (session) => `shared_with_user:${session.user.id}`, + }, + all: { + showOwner: true, + showMetadata: false, + requiresOwner: true, + poll: false, + getQuery: () => "", + }, +} as const satisfies Record; + +export const DEFAULT_WORKSPACE_FILTER: WorkspaceFilter = "mine"; + +const FILTER_ORDER = Object.keys(WORKSPACE_FILTERS) as WorkspaceFilter[]; + +/** The filters `session` may select, minus the ones `unsupported` lists. */ +export function availableFilters( + session: SessionData, + unsupported: ReadonlySet = new Set(), +): WorkspaceFilter[] { + if (session.kind !== "signedIn") { + return []; + } + const owner = isOwner(session.user); + return FILTER_ORDER.filter( + (filter) => + !unsupported.has(filter) && + (owner || !WORKSPACE_FILTERS[filter].requiresOwner), + ); +} diff --git a/src/workspace/workspacesProvider.ts b/src/workspace/workspacesProvider.ts index b6b4e116ca..06567e8666 100644 --- a/src/workspace/workspacesProvider.ts +++ b/src/workspace/workspacesProvider.ts @@ -21,6 +21,8 @@ import { import { type CoderApi } from "../api/coderApi"; import { type Logger } from "../logging/logger"; +import { WORKSPACE_FILTERS, type WorkspaceFilterConfig } from "./filters"; + import type { SessionData, SessionState } from "../deployment/sessionStore"; export enum WorkspaceQuery { @@ -29,35 +31,6 @@ export enum WorkspaceQuery { Shared = "shared", } -type SignedInSession = Extract; - -/** Per-view rendering behavior and search query, keyed by workspace view. */ -interface WorkspaceQueryConfig { - readonly showOwner: boolean; - readonly showMetadata: boolean; - readonly getQuery: (session: SignedInSession) => string; -} - -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; - export interface WorkspaceProviderOptions { readonly refreshIntervalMs?: number; /** @@ -85,7 +58,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; @@ -99,7 +72,7 @@ export class WorkspaceProvider private readonly sessionState: SessionState, private readonly options: WorkspaceProviderOptions = {}, ) { - this.config = WORKSPACE_QUERY_CONFIG[getWorkspacesQuery]; + this.config = WORKSPACE_FILTERS[getWorkspacesQuery]; this.sessionChangeDisposable = this.sessionState.onDidChange(() => { this.clear(); void this.fetchAndRefresh(); diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 708440cbb0..848abf10a1 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -594,10 +594,10 @@ export async function flush(): Promise { * Drain only the microtask queue. Use instead of flush() under fake timers, * which leave the macrotask queue (setImmediate) untouched. */ -export async function flushPromises(): Promise { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); +export async function flushPromises(rounds = 3): Promise { + for (let i = 0; i < rounds; i++) { + await Promise.resolve(); + } } /** 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..31adca2810 --- /dev/null +++ b/test/unit/api/api-helper.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { isOwner } from "@/api/api-helper"; + +import { createMockUser } 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 }) => { + const user = roles + ? createMockUser({ + roles: roles.map((name) => ({ name, display_name: name })), + }) + : undefined; + + expect(isOwner(user)).toBe(owner); + }); +}); diff --git a/test/unit/webviews/workspaces/harness.ts b/test/unit/webviews/workspaces/harness.ts new file mode 100644 index 0000000000..556ed09a71 --- /dev/null +++ b/test/unit/webviews/workspaces/harness.ts @@ -0,0 +1,172 @@ +import { vi } from "vitest"; +import * as vscode from "vscode"; + +import { WorkspacesPanelProvider } from "@/webviews/workspaces/panelProvider"; +import { WorkspaceStore, type PollOptions } from "@/webviews/workspaces/store"; + +import { + createMockLogger, + createMockUser, + flushPromises, + MockWorkspacesClient, + TestSessionStore, +} from "../../../mocks/testHelpers"; + +import type { + Workspace, + WorkspaceAgent, +} from "coder/site/src/api/typesGenerated"; + +import type { CoderApi } from "@/api/coderApi"; + +import type { CommandDef, WorkspacesUpdate } from "@repo/shared"; + +export const DEPLOYMENT_URL = "https://coder.example.com"; + +export const DEPLOYMENT = { + url: DEPLOYMENT_URL, + safeHostname: "coder.example.com", +}; + +export const OWNER = createMockUser({ + roles: [{ name: "owner", display_name: "Owner" }], +}); + +/** Stands in for CoderApi at the boundaries the panel touches. */ +export class MockClient extends MockWorkspacesClient { + readonly getHost = vi.fn((): string | undefined => DEPLOYMENT_URL); +} + +const disposables: vscode.Disposable[] = []; + +/** Stop the pollers so their timers do not outlive the test. */ +export function disposeHarnesses(): void { + for (const disposable of disposables.splice(0)) { + disposable.dispose(); + } +} + +function createStoreWith(client: MockClient, options?: Partial) { + const session = new TestSessionStore(); + const store = new WorkspaceStore( + // Cast needed: the mock implements only the CoderApi methods used here + client as unknown as CoderApi, + createMockLogger(), + session, + options, + ); + disposables.push(store); + + const updates: WorkspacesUpdate[] = []; + store.onDidChange((update) => updates.push(update)); + + return { + client, + session, + store, + updates, + /** Reveal the store, which lists, and wait for the fetch. */ + show: () => store.setVisible(true), + /** The value of every push that carried `key`, oldest first. */ + pushes: (key: K) => + updates + .filter((update) => update[key] !== undefined) + .map((update) => update[key] as NonNullable), + last(key: K) { + return this.pushes(key).at(-1); + }, + }; +} + +export function createStore(options?: Partial) { + return createStoreWith(new MockClient(), options); +} + +export function createPanel() { + const client = new MockClient(); + const base = createStoreWith(client); + const openWorkspace = vi.fn( + (_workspace: Workspace, _agent: WorkspaceAgent | undefined) => + Promise.resolve(true), + ); + const provider = new WorkspacesPanelProvider({ + extensionUri: vscode.Uri.file("/test/extension"), + client: client as unknown as CoderApi, + logger: createMockLogger(), + store: base.store, + openWorkspace, + }); + disposables.push(provider); + + let posted: Array<{ type: string; data?: unknown }> = []; + const messageEmitter = new vscode.EventEmitter(); + const visibilityEmitter = new vscode.EventEmitter(); + // Resolved while hidden so tests can stage responses before the first fetch. + let visible = false; + + const view: vscode.WebviewView = { + viewType: WorkspacesPanelProvider.viewType, + webview: { + options: { enableScripts: false, localResourceRoots: [] }, + html: "", + cspSource: "mock-csp", + onDidReceiveMessage: messageEmitter.event, + postMessage: (msg: unknown) => { + posted.push(msg as { type: string; data?: unknown }); + return Promise.resolve(true); + }, + asWebviewUri: (uri: vscode.Uri) => uri, + }, + get visible() { + return visible; + }, + show: vi.fn(), + onDidChangeVisibility: visibilityEmitter.event, + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + }; + + provider.resolveWebviewView( + view, + {} as vscode.WebviewViewResolveContext, + new vscode.CancellationTokenSource().token, + ); + + const setVisible = (next: boolean) => { + visible = next; + visibilityEmitter.fire(); + }; + + return { + ...base, + provider, + view, + openWorkspace, + setVisible, + show: async () => { + setVisible(true); + await base.store.settled; + }, + /** Send a command from the webview and wait for its handler. */ + send: async

( + def: CommandDef

, + ...args: P extends void ? [] : [params: P] + ) => { + messageEmitter.fire({ method: def.method, params: args[0] }); + await flushPromises(); + await base.store.settled; + }, + /** Send anything the webview could post, valid or not. */ + sendRaw: async (message: unknown) => { + messageEmitter.fire(message); + await flushPromises(); + }, + /** Every state update pushed to the webview, oldest first. */ + pushedUpdates: () => + posted + .filter((message) => message.type === "stateUpdated") + .map((message) => message.data as WorkspacesUpdate), + clearPushes: () => { + posted = []; + }, + }; +} diff --git a/test/unit/webviews/workspaces/panelProvider.test.ts b/test/unit/webviews/workspaces/panelProvider.test.ts new file mode 100644 index 0000000000..036f361dea --- /dev/null +++ b/test/unit/webviews/workspaces/panelProvider.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { agent, workspace } from "@repo/mocks"; +import { WorkspacesApi, type OpenWorkspaceParams } from "@repo/shared"; + +import { + MockConfigurationProvider, + setActiveColorTheme, +} from "../../../mocks/testHelpers"; + +import { createPanel, DEPLOYMENT_URL, disposeHarnesses } from "./harness"; + +/** A workspace of "alice" with a single "main" agent. */ +const aliceWorkspace = () => + workspace({ + id: "workspace-1", + name: "dev", + owner_name: "alice", + agents: [agent({ id: "agent-1", name: "main" })], + }); + +describe("WorkspacesPanelProvider", () => { + beforeEach(() => { + vi.resetAllMocks(); + new MockConfigurationProvider(); + }); + + afterEach(() => { + disposeHarnesses(); + }); + + it("renders the workspaces bundle", () => { + const h = createPanel(); + + expect(h.view.webview.html).toContain("Coder Workspaces"); + expect(h.view.webview.options.enableScripts).toBe(true); + }); + + it("replays the whole state when the webview signals ready", async () => { + const h = createPanel(); + h.client.respondOnce([aliceWorkspace()]); + await h.show(); + h.clearPushes(); + + await h.send(WorkspacesApi.ready); + + expect(h.pushedUpdates()).toEqual([ + { + capabilities: { authenticated: true, filters: ["mine", "shared"] }, + workspaces: { + filter: "mine", + workspaces: [expect.objectContaining({ id: "workspace-1" })], + loading: false, + }, + metadata: {}, + error: null, + }, + ]); + }); + + it("pushes one update carrying only the fields that changed", async () => { + const h = createPanel(); + await h.show(); + h.clearPushes(); + h.client.respondOnce([aliceWorkspace()]); + + await h.send(WorkspacesApi.refresh); + + const updates = h.pushedUpdates(); + expect(updates.map(Object.keys)).toEqual([["workspaces"], ["workspaces"]]); + expect(updates.at(-1)?.workspaces?.workspaces).toHaveLength(1); + }); + + it("pushes fresh workspaces on reveal without resending the rest", async () => { + const h = createPanel(); + await h.show(); + h.setVisible(false); + h.clearPushes(); + h.client.respondOnce([aliceWorkspace()]); + + h.setVisible(true); + await h.store.settled; + + expect(h.pushedUpdates()).toEqual([ + { + workspaces: { + filter: "mine", + workspaces: [expect.objectContaining({ id: "workspace-1" })], + loading: false, + }, + }, + ]); + }); + + it("pushes nothing when the color theme changes", async () => { + const h = createPanel(); + await h.show(); + h.clearPushes(); + + setActiveColorTheme(vscode.ColorThemeKind.Light); + + expect(h.pushedUpdates()).toEqual([]); + }); + + it("pushes nothing once disposed", async () => { + const h = createPanel(); + await h.show(); + h.provider.dispose(); + h.clearPushes(); + + h.setVisible(true); + await h.store.settled; + + expect(h.pushedUpdates()).toEqual([]); + }); + + it("ignores an unrecognized message", async () => { + const h = createPanel(); + await h.show(); + h.clearPushes(); + + await h.sendRaw({ method: "nope" }); + + expect(h.pushedUpdates()).toEqual([]); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + }); + + describe("commands", () => { + it("switches the filter", async () => { + const h = createPanel(); + await h.show(); + + await h.send(WorkspacesApi.setFilter, { filter: "shared" }); + + expect(h.store.state.workspaces.filter).toBe("shared"); + }); + + it("watches the agents the webview is showing", async () => { + const h = createPanel(); + h.client.respondOnce([aliceWorkspace()]); + await h.show(); + + await h.send(WorkspacesApi.watchAgents, { agentIds: ["agent-1"] }); + + expect([...h.client.metadataStreams.keys()]).toEqual(["agent-1"]); + }); + }); + + describe("openWorkspace", () => { + it.each<{ name: string; params: OpenWorkspaceParams; agent?: string }>([ + { 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 = createPanel(); + h.client.respondOnce([aliceWorkspace()]); + await h.show(); + + await h.send(WorkspacesApi.openWorkspace, params); + + expect(h.openWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ name: "dev", owner_name: "alice" }), + agentName ? expect.objectContaining({ name: agentName }) : undefined, + ); + }); + + interface FailureCase { + name: string; + params: OpenWorkspaceParams; + refuse?: boolean; + 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" }, + refuse: true, + reported: "no SSH binary", + }, + ])("reports $name", async ({ params, refuse, reported }) => { + const h = createPanel(); + h.client.respondOnce([aliceWorkspace()]); + await h.show(); + if (refuse) { + h.openWorkspace.mockRejectedValueOnce(new Error(reported)); + } + + await h.send(WorkspacesApi.openWorkspace, params); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(reported); + }); + }); + + describe("viewInDashboard", () => { + it.each([ + { page: "workspace", url: `${DEPLOYMENT_URL}/@alice/dev` }, + { page: "settings", url: `${DEPLOYMENT_URL}/@alice/dev/settings` }, + ] as const)( + "opens the $page page in the browser", + async ({ page, url }) => { + const h = createPanel(); + h.client.respondOnce([aliceWorkspace()]); + await h.show(); + + await h.send(WorkspacesApi.viewInDashboard, { + workspaceId: "workspace-1", + page, + }); + + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse(url), + ); + }, + ); + + it("opens nothing while signed out of every deployment", async () => { + const h = createPanel(); + h.client.respondOnce([aliceWorkspace()]); + await h.show(); + h.client.getHost.mockReturnValue(undefined); + + await h.send(WorkspacesApi.viewInDashboard, { + workspaceId: "workspace-1", + page: "settings", + }); + + expect(vscode.env.openExternal).not.toHaveBeenCalled(); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + }); + + it("reports a workspace that is no longer listed", async () => { + const h = createPanel(); + await h.show(); + + await h.send(WorkspacesApi.viewInDashboard, { + workspaceId: "gone", + page: "settings", + }); + + expect(vscode.env.openExternal).not.toHaveBeenCalled(); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Workspace is no longer available", + ); + }); + }); +}); diff --git a/test/unit/webviews/workspaces/store.test.ts b/test/unit/webviews/workspaces/store.test.ts new file mode 100644 index 0000000000..adcdc58cc5 --- /dev/null +++ b/test/unit/webviews/workspaces/store.test.ts @@ -0,0 +1,441 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { agent, agentMetadata, workspace } from "@repo/mocks"; + +import { createStore, DEPLOYMENT, disposeHarnesses, OWNER } from "./harness"; + +import type { FilteredWorkspaces } from "@repo/shared"; + +type Store = ReturnType; + +function queryRejected(): Error { + return Object.assign(new Error("invalid query"), { + isAxiosError: true, + response: { status: 400 }, + }); +} + +function ids(listed: FilteredWorkspaces | undefined): string[] { + return (listed?.workspaces ?? []).map((entry) => entry.id); +} + +const withAgent = () => + workspace({ id: "workspace-1", agents: [agent({ id: "agent-1" })] }); + +describe("WorkspaceStore", () => { + afterEach(() => { + disposeHarnesses(); + vi.useRealTimers(); + }); + + it("lists the workspaces of the signed-in user", async () => { + const h = createStore(); + h.client.respondOnce([workspace({ id: "workspace-1" })]); + + await h.show(); + + expect(h.client.getWorkspaces).toHaveBeenCalledWith({ q: "owner:me" }); + expect(h.last("workspaces")).toEqual({ + filter: "mine", + workspaces: [expect.objectContaining({ id: "workspace-1" })], + loading: false, + } satisfies FilteredWorkspaces); + expect(h.store.state).toMatchObject({ + capabilities: { authenticated: true, filters: ["mine", "shared"] }, + metadata: {}, + error: null, + }); + expect(h.store.findWorkspace("workspace-1")?.id).toBe("workspace-1"); + expect(h.store.findWorkspace("gone")).toBeUndefined(); + }); + + it("pushes nothing until it is visible", async () => { + const h = createStore(); + + await h.store.settled; + + expect(h.updates).toEqual([]); + }); + + it("reports a signed out session without fetching", async () => { + const h = createStore(); + h.session.signOut(); + + await h.show(); + + expect(h.client.getWorkspaces).not.toHaveBeenCalled(); + expect(h.store.state.capabilities).toEqual({ + authenticated: false, + filters: [], + }); + }); + + describe("polling", () => { + it("picks up changes on the interval", async () => { + vi.useFakeTimers(); + const h = createStore({ intervalMs: 5_000 }); + h.client.respondOnce([workspace({ id: "first" })]); + h.client.respondOnce([workspace({ id: "second" })]); + await h.show(); + + await vi.advanceTimersByTimeAsync(5_000); + + expect(h.pushes("workspaces").map(ids)).toEqual([ + [], + ["first"], + ["second"], + ]); + }); + + it("says nothing about a poll that changed nothing", async () => { + vi.useFakeTimers(); + const h = createStore({ intervalMs: 5_000 }); + h.client.respondOnce([workspace({ id: "workspace-1" })]); + h.client.respondOnce([workspace({ id: "workspace-1" })]); + await h.show(); + const pushed = h.updates.length; + + await vi.advanceTimersByTimeAsync(5_000); + + // Not even the loading flag, which would flicker every interval. + expect(h.updates).toHaveLength(pushed); + }); + + it.each<{ name: string; stop: (h: Store) => void }>([ + { name: "hidden", stop: (h) => void h.store.setVisible(false) }, + { name: "disposed", stop: (h) => h.store.dispose() }, + ])("stops while $name", async ({ stop }) => { + vi.useFakeTimers(); + const h = createStore({ intervalMs: 5_000 }); + h.client.respondOnce([workspace({ id: "first" })]); + h.client.respondOnce([workspace({ id: "second" })]); + await h.show(); + + stop(h); + await vi.advanceTimersByTimeAsync(60_000); + + expect(h.pushes("workspaces").map(ids)).toEqual([[], ["first"]]); + }); + + it("only polls filters that are cheap to list", async () => { + vi.useFakeTimers(); + const h = createStore({ intervalMs: 5_000 }); + await h.show(); + await h.store.setFilter("shared"); + const listed = h.client.getWorkspaces.mock.calls.length; + + await vi.advanceTimersByTimeAsync(60_000); + + expect(h.client.getWorkspaces).toHaveBeenCalledTimes(listed); + }); + + it("backs off while fetches keep failing, up to a maximum", async () => { + vi.useFakeTimers(); + const h = createStore({ intervalMs: 5_000, maxIntervalMs: 20_000 }); + h.client.getWorkspaces.mockRejectedValue(new Error("network down")); + await h.show(); + + // Each retry waits twice as long as the last, then holds at the cap. + for (const [delay, fetches] of [ + [5_000, 1], + [5_000, 2], + [20_000, 3], + [20_000, 4], + ]) { + await vi.advanceTimersByTimeAsync(delay); + expect(h.client.getWorkspaces).toHaveBeenCalledTimes(fetches); + } + }); + }); + + describe("loading", () => { + it("reports the list as loading until it arrives", async () => { + const h = createStore(); + h.client.respondOnce([workspace({ id: "workspace-1" })]); + + await h.show(); + + expect(h.pushes("workspaces")).toEqual([ + { filter: "mine", workspaces: [], loading: true }, + { filter: "mine", workspaces: [expect.anything()], loading: false }, + ]); + }); + + it.each<{ name: string; act: (h: Store) => Promise }>([ + { name: "a filter switch", act: (h) => h.store.setFilter("shared") }, + { name: "a deliberate refresh", act: (h) => h.store.refresh() }, + ])("reports loading again for $name", async ({ act }) => { + const h = createStore(); + await h.show(); + + await act(h); + + expect(h.pushes("workspaces").map((listed) => listed.loading)).toEqual([ + true, + false, + true, + false, + ]); + }); + + it.each<{ name: string; fail: () => Error }>([ + { name: "fails", fail: () => new Error("network down") }, + { name: "is rejected", fail: queryRejected }, + ])("stops loading when the fetch $name", async ({ fail }) => { + const h = createStore(); + await h.show(); + h.client.getWorkspaces.mockRejectedValueOnce(fail()); + + await h.store.setFilter("shared"); + + expect(h.last("workspaces")?.loading).toBe(false); + }); + }); + + describe("filters", () => { + it("lists the filter the webview selected", async () => { + const h = createStore(); + await h.show(); + + await h.store.setFilter("shared"); + + expect(h.client.getWorkspaces).toHaveBeenLastCalledWith({ + q: "shared_with_user:current-user", + }); + expect(h.last("workspaces")?.filter).toBe("shared"); + }); + + it("offers the filters of whoever signed in", async () => { + const h = createStore(); + await h.show(); + expect(h.store.state.capabilities.filters).toEqual(["mine", "shared"]); + + h.session.signIn(DEPLOYMENT, OWNER); + await h.store.settled; + + expect(h.last("capabilities")?.filters).toContain("all"); + }); + + it("ignores a filter it does not offer", async () => { + const h = createStore(); + await h.show(); + + await h.store.setFilter("all"); + + expect(h.last("workspaces")?.filter).toBe("mine"); + expect(h.client.getWorkspaces).toHaveBeenCalledTimes(1); + }); + + it("falls back when the selected filter is no longer offered", async () => { + const h = createStore(); + h.session.signIn(DEPLOYMENT, OWNER); + await h.show(); + await h.store.setFilter("all"); + + h.session.signInAs("someone-else"); + await h.store.settled; + + expect(h.last("capabilities")?.filters).toEqual(["mine", "shared"]); + expect(h.last("workspaces")?.filter).toBe("mine"); + }); + }); + + describe("session changes", () => { + it("clears the workspaces and lists them again", async () => { + const h = createStore(); + h.client.respondOnce([workspace({ id: "workspace-1" })]); + await h.show(); + + h.session.signInAs("someone-else"); + await h.store.settled; + + expect(ids(h.last("workspaces"))).toEqual([]); + expect(h.last("capabilities")).toMatchObject({ authenticated: true }); + expect(h.client.getWorkspaces).toHaveBeenCalledTimes(2); + }); + }); + + describe("failed fetches", () => { + it("reports a failure, then clears it once a fetch succeeds", async () => { + const h = createStore(); + h.client.respondOnce([workspace({ id: "workspace-1" })]); + await h.show(); + h.client.getWorkspaces.mockRejectedValueOnce(new Error("network down")); + + await h.store.refresh(); + expect(h.last("error")).toBe("network down"); + expect(ids(h.last("workspaces"))).toEqual([]); + + await h.store.refresh(); + + expect(h.last("error")).toBeNull(); + }); + + it("stops offering a filter the deployment rejects", async () => { + const h = createStore(); + await h.show(); + h.client.getWorkspaces.mockRejectedValueOnce(queryRejected()); + h.client.respondOnce([workspace({ id: "workspace-1" })]); + + await h.store.setFilter("shared"); + + expect(h.last("capabilities")?.filters).toEqual(["mine"]); + // Falls back to a filter that loads instead of leaving a broken one. + expect(h.last("workspaces")).toMatchObject({ + filter: "mine", + workspaces: [expect.objectContaining({ id: "workspace-1" })], + }); + expect(h.last("error")).toBeNull(); + }); + + it("keeps the default filter when its query is rejected", async () => { + const h = createStore(); + h.client.getWorkspaces.mockRejectedValueOnce(queryRejected()); + + await h.show(); + + expect(h.store.state.capabilities.filters).toEqual(["mine", "shared"]); + expect(h.last("error")).toBe("invalid query"); + }); + + it("offers a rejected filter again on refresh", async () => { + const h = createStore(); + await h.show(); + h.client.getWorkspaces.mockRejectedValueOnce(queryRejected()); + await h.store.setFilter("shared"); + + await h.store.refresh(); + + expect(h.last("capabilities")?.filters).toEqual(["mine", "shared"]); + }); + }); + + describe("agent metadata", () => { + it("watches nothing until the webview asks, then pushes reports", async () => { + const h = createStore(); + h.client.respondOnce([withAgent()]); + await h.show(); + expect(h.client.metadataStreams.size).toBe(0); + + // The agent that is not listed is not watched. + await h.store.setWatchedAgents(["agent-1", "not-listed"]); + expect([...h.client.metadataStreams.keys()]).toEqual(["agent-1"]); + + h.client.metadataStreams + .get("agent-1") + ?.pushMessage({ data: [agentMetadata()] }); + + expect(h.last("metadata")).toEqual({ + "agent-1": { metadata: [agentMetadata()], error: null, loading: false }, + }); + }); + + it("stops reporting what the webview stopped showing", async () => { + const h = createStore(); + h.client.respondOnce([withAgent()]); + await h.show(); + await h.store.setWatchedAgents(["agent-1"]); + + await h.store.setWatchedAgents([]); + + expect(h.last("metadata")).toEqual({}); + }); + + it("lets go of the sockets while hidden and takes them back on reveal", async () => { + const h = createStore(); + h.client.respondOnce([withAgent()]); + await h.show(); + await h.store.setWatchedAgents(["agent-1"]); + h.client.metadataStreams + .get("agent-1") + ?.pushMessage({ data: [agentMetadata()] }); + const opened = vi.spyOn(h.client, "watchAgentMetadata"); + + await h.store.setVisible(false); + expect(h.last("metadata")).toEqual({}); + + h.client.respondOnce([withAgent()]); + await h.store.setVisible(true); + + // The socket outlived the hide, so its last report comes straight back. + expect(opened).not.toHaveBeenCalled(); + expect(h.last("metadata")).toEqual({ + "agent-1": { metadata: [agentMetadata()], error: null, loading: false }, + }); + }); + + it("keeps the workspaces when a metadata socket fails", async () => { + const h = createStore(); + vi.spyOn(h.client, "watchAgentMetadata").mockRejectedValue( + new Error("socket refused"), + ); + h.client.respondOnce([withAgent()]); + await h.show(); + + await h.store.setWatchedAgents(["agent-1"]); + + expect(ids(h.last("workspaces"))).toEqual(["workspace-1"]); + expect(h.last("error")).toBeNull(); + expect(h.last("metadata")).toEqual({ + "agent-1": { + metadata: [], + error: "Failed to query metadata: socket refused", + loading: false, + }, + }); + }); + }); + + describe("under load", () => { + it("applies only the newest of many overlapping fetches", async () => { + const h = createStore(); + const fetches = Array.from({ length: 4 }, () => h.client.pending()); + void h.store.setVisible(true); + for (let i = 1; i < fetches.length; i++) { + void h.store.refresh(); + } + + // Out of order, with the newest fetch resolving in the middle. + fetches[2].resolve([workspace({ id: "third" })]); + fetches[0].resolve([workspace({ id: "first" })]); + fetches[3].resolve([workspace({ id: "newest" })]); + fetches[1].resolve([workspace({ id: "second" })]); + await h.store.settled; + + expect(h.pushes("workspaces").flatMap(ids)).toEqual(["newest"]); + }); + + it("settles on the last of many filter switches", async () => { + const h = createStore(); + h.session.signIn(DEPLOYMENT, OWNER); + await h.show(); + + void h.store.setFilter("shared"); + void h.store.setFilter("all"); + void h.store.setFilter("mine"); + await h.store.setFilter("shared"); + + expect(h.last("workspaces")?.filter).toBe("shared"); + expect(h.client.getWorkspaces).toHaveBeenLastCalledWith({ + q: `shared_with_user:${OWNER.id}`, + }); + }); + + it("watches a large fleet of agents", async () => { + const h = createStore(); + const workspaces = Array.from({ length: 50 }, (_, i) => + workspace({ + id: `workspace-${i}`, + agents: [agent({ id: `agent-${i}` })], + }), + ); + h.client.respondOnce(workspaces); + await h.show(); + + await h.store.setWatchedAgents(workspaces.map((_, i) => `agent-${i}`)); + + expect(h.client.metadataStreams.size).toBe(50); + expect(Object.keys(h.last("metadata") ?? {})).toHaveLength(50); + }); + }); +}); diff --git a/test/unit/workspace/agentMetadataTracker.test.ts b/test/unit/workspace/agentMetadataTracker.test.ts new file mode 100644 index 0000000000..fe3f0c9ccc --- /dev/null +++ b/test/unit/workspace/agentMetadataTracker.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AgentMetadataTracker } from "@/workspace/agentMetadataTracker"; + +import { agentMetadata } from "@repo/mocks"; + +import { MockWorkspacesClient } from "../../mocks/testHelpers"; + +import type { CoderApi } from "@/api/coderApi"; + +import type { AgentMetadataMap, AgentMetadataState } from "@repo/shared"; + +type MockAgentStream = NonNullable< + ReturnType["stream"]> +>; + +const LINGER_MS = 1_000; +const PENDING = { metadata: [], error: null, loading: true }; +const REPORTED = { metadata: [agentMetadata()], error: null, loading: false }; + +function setup() { + const client = new MockWorkspacesClient(); + const tracker = new AgentMetadataTracker(client as unknown as CoderApi, { + lingerMs: LINGER_MS, + }); + const reports: AgentMetadataMap[] = []; + tracker.onDidChange((metadata) => reports.push(metadata)); + return { + client, + tracker, + reports, + opened: vi.spyOn(client, "watchAgentMetadata"), + stream: (agentId: string) => client.metadataStreams.get(agentId), + }; +} + +describe("AgentMetadataTracker", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("reports the agents it was given, with one socket each", async () => { + const { opened, tracker, reports } = setup(); + + await tracker.setWatched(["agent-1", "agent-2"]); + await tracker.setWatched(["agent-2", "agent-3"]); + + expect(reports).toEqual([ + { "agent-1": PENDING, "agent-2": PENDING }, + { "agent-2": PENDING, "agent-3": PENDING }, + ]); + expect(tracker.metadata).toEqual(reports.at(-1)); + expect(opened.mock.calls.map(([agentId]) => agentId)).toEqual([ + "agent-1", + "agent-2", + "agent-3", + ]); + }); + + it("reports nothing when the watched set does not change", async () => { + const { tracker, reports } = setup(); + + await tracker.setWatched([]); + + expect(reports).toEqual([]); + }); + + it("opens a large fleet in parallel", async () => { + const { client, tracker } = setup(); + + await tracker.setWatched( + Array.from({ length: 100 }, (_, i) => `agent-${i}`), + ); + + expect(client.metadataStreams.size).toBe(100); + expect(Object.keys(tracker.metadata)).toHaveLength(100); + }); + + interface ReportCase { + name: string; + send: (stream: MockAgentStream) => void; + reported: AgentMetadataState; + } + + it.each([ + { + name: "metadata", + send: (stream) => stream.pushMessage({ data: [agentMetadata()] }), + reported: REPORTED, + }, + { + name: "a failure", + send: (stream) => stream.pushError(new Error("boom")), + reported: { + metadata: [], + error: "Failed to query metadata: boom", + loading: false, + }, + }, + ])("reports $name an agent sends", async ({ send, reported }) => { + const { tracker, stream } = setup(); + await tracker.setWatched(["agent-1"]); + + send(stream("agent-1")!); + + expect(tracker.metadata).toEqual({ "agent-1": reported }); + }); + + describe("letting go", () => { + it("keeps the socket for a while, in case the agent comes back", async () => { + const { tracker, reports, stream } = setup(); + await tracker.setWatched(["agent-1"]); + const socket = stream("agent-1"); + + await tracker.setWatched([]); + expect(tracker.metadata).toEqual({}); + expect(socket?.close).not.toHaveBeenCalled(); + + // What it reports while lingering is recorded, but nobody is told. + const reported = reports.length; + socket?.pushMessage({ data: [agentMetadata()] }); + expect(reports).toHaveLength(reported); + + vi.advanceTimersByTime(LINGER_MS); + + expect(socket?.close).toHaveBeenCalled(); + }); + + it("reuses a lingering socket, with what it already reported", async () => { + const { opened, tracker, stream } = setup(); + await tracker.setWatched(["agent-1"]); + stream("agent-1")?.pushMessage({ data: [agentMetadata()] }); + + // Collapsing and expanding a row repeatedly must not reconnect. + for (let i = 0; i < 5; i++) { + await tracker.setWatched([]); + vi.advanceTimersByTime(LINGER_MS / 2); + await tracker.setWatched(["agent-1"]); + } + + expect(opened).toHaveBeenCalledTimes(1); + expect(tracker.metadata).toEqual({ "agent-1": REPORTED }); + }); + + it("keeps the agents that never stopped being watched", async () => { + const { tracker, reports } = setup(); + await tracker.setWatched(["agent-1", "agent-2"]); + + await tracker.setWatched(["agent-1"]); + vi.advanceTimersByTime(LINGER_MS); + + expect(tracker.metadata).toEqual({ "agent-1": PENDING }); + expect(reports.at(-1)).toEqual({ "agent-1": PENDING }); + }); + }); + + describe("recovering", () => { + it("reports a socket that never opened, and retries it", async () => { + const { client, tracker } = setup(); + vi.spyOn(client, "watchAgentMetadata").mockRejectedValueOnce( + new Error("socket refused"), + ); + + await tracker.setWatched(["agent-1"]); + expect(tracker.metadata).toEqual({ + "agent-1": { + metadata: [], + error: "Failed to query metadata: socket refused", + loading: false, + }, + }); + + await tracker.setWatched(["agent-1"]); + + expect(client.metadataStreams.has("agent-1")).toBe(true); + }); + + it("reopens a socket that died on its own", async () => { + const { opened, tracker, stream } = setup(); + await tracker.setWatched(["agent-1"]); + stream("agent-1")?.emit("close", { + code: 1006, + reason: "gone", + wasClean: false, + }); + + await tracker.setWatched(["agent-1"]); + + expect(opened).toHaveBeenCalledTimes(2); + }); + }); + + describe("shutting down", () => { + it("closes everything and stops reporting once disposed", async () => { + const { client, tracker, reports, stream } = setup(); + await tracker.setWatched(["agent-1", "agent-2"]); + await tracker.setWatched(["agent-1"]); + const dropped = stream("agent-1"); + const reported = reports.length; + + tracker.dispose(); + dropped?.pushMessage({ data: [agentMetadata()] }); + + for (const socket of client.metadataStreams.values()) { + expect(socket.close).toHaveBeenCalled(); + } + expect(reports).toHaveLength(reported); + expect(tracker.metadata).toEqual({}); + }); + + it("drops a socket that opened after disposal", async () => { + const { client, tracker } = setup(); + const opening = tracker.setWatched(["agent-1"]); + + tracker.dispose(); + await opening; + + expect(client.metadataStreams.get("agent-1")?.close).toHaveBeenCalled(); + expect(tracker.metadata).toEqual({}); + }); + }); +}); diff --git a/test/unit/workspace/filters.test.ts b/test/unit/workspace/filters.test.ts new file mode 100644 index 0000000000..df0875d8d3 --- /dev/null +++ b/test/unit/workspace/filters.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { availableFilters, WORKSPACE_FILTERS } from "@/workspace/filters"; + +import { createMockUser } from "../../mocks/testHelpers"; + +import type { SessionData } from "@/deployment/sessionStore"; + +import type { WorkspaceFilter } from "@repo/shared"; + +type SignedInSession = Extract; + +function signedIn(roles: string[] = []): SignedInSession { + return { + kind: "signedIn", + deployment: { + url: "https://coder.example.com", + safeHostname: "coder.example.com", + }, + user: createMockUser({ + id: "user-1", + roles: roles.map((name) => ({ name, display_name: name })), + }), + }; +} + +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:user-1" }, + { filter: "all", query: "" }, + ])("queries $filter workspaces", ({ filter, query }) => { + expect(WORKSPACE_FILTERS[filter].getQuery(signedIn())).toBe(query); + }); +}); + +describe("availableFilters", () => { + interface AvailabilityCase { + name: string; + session: SessionData; + unsupported?: WorkspaceFilter[]; + offered: WorkspaceFilter[]; + } + + it.each([ + { name: "signed out", session: SIGNED_OUT, offered: [] }, + { name: "a member", session: signedIn(), offered: ["mine", "shared"] }, + { + name: "an owner", + session: signedIn(["owner"]), + offered: ["mine", "shared", "all"], + }, + { + name: "a rejected query", + session: signedIn(["owner"]), + unsupported: ["shared"], + offered: ["mine", "all"], + }, + ])("offers $offered to $name", ({ session, unsupported, offered }) => { + expect(availableFilters(session, new Set(unsupported))).toEqual(offered); + }); +}); diff --git a/test/webview/workspaces/useWorkspaces.test.tsx b/test/webview/workspaces/useWorkspaces.test.tsx new file mode 100644 index 0000000000..9157168f1d --- /dev/null +++ b/test/webview/workspaces/useWorkspaces.test.tsx @@ -0,0 +1,75 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { WorkspacesApi, type WorkspacesUpdate } from "@repo/shared"; +import { useWorkspaces } from "@repo/workspaces/hooks/useWorkspaces"; + +const sent: unknown[] = []; + +vi.stubGlobal( + "acquireVsCodeApi", + vi.fn(() => ({ + postMessage: (message: unknown) => sent.push(message), + getState: () => undefined, + setState: () => {}, + })), +); + +/** Push a state update the way the extension does. */ +async function push(update: WorkspacesUpdate) { + await act(async () => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: WorkspacesApi.stateUpdated.method, data: update }, + }), + ); + await Promise.resolve(); + }); +} + +const CAPABILITIES = { authenticated: true, filters: ["mine"] } as const; +const LISTED = { filter: "mine", workspaces: [], loading: false } as const; + +describe("useWorkspaces", () => { + it("asks for the state once its subscription is live", () => { + sent.length = 0; + + renderHook(() => useWorkspaces()); + + expect(sent).toEqual([ + { method: WorkspacesApi.ready.method, params: undefined }, + ]); + }); + + it("applies only the fields an update carries", async () => { + const { result } = renderHook(() => useWorkspaces()); + + await push({ capabilities: CAPABILITIES, workspaces: LISTED }); + await push({ error: "network down" }); + + expect(result.current.state).toEqual({ + capabilities: CAPABILITIES, + workspaces: LISTED, + error: "network down", + }); + + const shared = { filter: "shared", workspaces: [], loading: true } as const; + await push({ workspaces: shared }); + + expect(result.current.state).toEqual({ + capabilities: CAPABILITIES, + workspaces: shared, + error: "network down", + }); + }); + + it("stops applying updates once unmounted", async () => { + const { result, unmount } = renderHook(() => useWorkspaces()); + await push({ capabilities: CAPABILITIES }); + + unmount(); + await push({ error: "network down" }); + + expect(result.current.state).toEqual({ capabilities: CAPABILITIES }); + }); +}); 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", From 4797e49131a79295ff4403eea656763e2826c614 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 27 Aug 2026 16:08:35 +0000 Subject: [PATCH 2/2] refactor(workspaces): trim panel boilerplate and share the test webview mock - Drop WorkspacesPanelProvider.refresh() and its onDidChangeCurrentDeployment listener: every path that event covers already flows through the session store, which the store listens to - Collapse the options-bag constructor into Object.assign so handlers keep reading this.store etc. directly - Replace AgentMetadataTracker's single-knob options object with a lingerMs parameter - Remove the dead disposed guard in handleSessionChange; the session subscription is disposed synchronously - Promote the mock WebviewView into testHelpers.createMockWebviewView and reuse it from the tasks and workspaces test harnesses --- src/extension.ts | 3 - src/webviews/workspaces/panelProvider.ts | 22 ++---- src/webviews/workspaces/store.ts | 3 - src/workspace/agentMetadataTracker.ts | 20 ++---- test/mocks/testHelpers.ts | 69 +++++++++++++++++++ .../webviews/tasks/tasksPanelProvider.test.ts | 38 +++------- test/unit/webviews/workspaces/harness.ts | 55 ++++----------- .../workspace/agentMetadataTracker.test.ts | 7 +- 8 files changed, 105 insertions(+), 112 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 6f6e3ce290..c3fbd6bb6f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -331,9 +331,6 @@ async function doActivate( workspacesPanelProvider, { webviewOptions: { retainContextWhenHidden: true } }, ), - secretsManager.onDidChangeCurrentDeployment(() => - workspacesPanelProvider.refresh(), - ), ); } diff --git a/src/webviews/workspaces/panelProvider.ts b/src/webviews/workspaces/panelProvider.ts index e329ecab04..90efd37314 100644 --- a/src/webviews/workspaces/panelProvider.ts +++ b/src/webviews/workspaces/panelProvider.ts @@ -65,11 +65,11 @@ export class WorkspacesPanelProvider { public static readonly viewType = "coder.workspacesPanel"; - private readonly extensionUri: vscode.Uri; - private readonly client: CoderApi; - private readonly logger: Logger; - private readonly store: WorkspaceStore; - private readonly openWorkspace: WorkspacesPanelOptions["openWorkspace"]; + declare private readonly extensionUri: vscode.Uri; + declare private readonly client: CoderApi; + declare private readonly logger: Logger; + declare private readonly store: WorkspaceStore; + declare private readonly openWorkspace: WorkspacesPanelOptions["openWorkspace"]; private readonly requestHandlers = buildRequestHandlers(WorkspacesApi, {}); private readonly commandHandlers = buildCommandHandlers(WorkspacesApi, { @@ -85,17 +85,7 @@ export class WorkspacesPanelProvider private disposables: vscode.Disposable[] = []; constructor(options: WorkspacesPanelOptions) { - this.extensionUri = options.extensionUri; - this.client = options.client; - this.logger = options.logger; - this.store = options.store; - this.openWorkspace = options.openWorkspace; - } - - public refresh(): void { - this.store.refresh().catch((err: unknown) => { - this.logger.error("Failed to refresh workspaces", err); - }); + Object.assign(this, options); } resolveWebviewView( diff --git a/src/webviews/workspaces/store.ts b/src/webviews/workspaces/store.ts index 2cdeb0eabf..bdf3248505 100644 --- a/src/webviews/workspaces/store.ts +++ b/src/webviews/workspaces/store.ts @@ -314,9 +314,6 @@ export class WorkspaceStore implements vscode.Disposable { } private handleSessionChange(): void { - if (this.disposed) { - return; - } this.unsupportedFilters.clear(); this.requestedAgents = []; if (!this.state.capabilities.filters.includes(this.filter)) { diff --git a/src/workspace/agentMetadataTracker.ts b/src/workspace/agentMetadataTracker.ts index d0288d735e..f01bed372f 100644 --- a/src/workspace/agentMetadataTracker.ts +++ b/src/workspace/agentMetadataTracker.ts @@ -23,13 +23,6 @@ const PENDING: AgentMetadataState = { loading: true, }; -export interface AgentMetadataTrackerOptions { - /** How long a released socket stays open, in case it is wanted again. */ - readonly lingerMs: number; -} - -const DEFAULT_OPTIONS: AgentMetadataTrackerOptions = { lingerMs: 15_000 }; - interface WatchedAgent { /** Absent while the socket is opening, or after it failed to open. */ watcher?: AgentMetadataWatcher; @@ -54,7 +47,6 @@ function needsSocket(watched: WatchedAgent | undefined): boolean { export class AgentMetadataTracker implements vscode.Disposable { private readonly changeEmitter = new vscode.EventEmitter(); private readonly watched = new Map(); - private readonly options: AgentMetadataTrackerOptions; public readonly onDidChange = this.changeEmitter.event; @@ -62,10 +54,9 @@ export class AgentMetadataTracker implements vscode.Disposable { constructor( private readonly client: CoderApi, - options: Partial = {}, - ) { - this.options = { ...DEFAULT_OPTIONS, ...options }; - } + /** How long a released socket stays open, in case it is wanted again. */ + private readonly lingerMs = 15_000, + ) {} /** The latest report of every watched agent, keyed by agent id. */ public get metadata(): AgentMetadataMap { @@ -129,10 +120,7 @@ export class AgentMetadataTracker implements vscode.Disposable { if (watched.closing) { return false; } - watched.closing = setTimeout( - () => this.close(agentId), - this.options.lingerMs, - ); + watched.closing = setTimeout(() => this.close(agentId), this.lingerMs); return true; } diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 848abf10a1..0ee045a459 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -803,6 +803,75 @@ 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, so providers can + * register, dispose, and be driven through the hooks. Starts hidden; reveal + * it with `hooks.setVisible` once responses are staged. + */ +export function createMockWebviewView(viewType: string): { + view: vscode.WebviewView; + hooks: WebviewViewTestHooks; +} { + const messageEmitter = new vscode.EventEmitter(); + const visibilityEmitter = new vscode.EventEmitter(); + const disposeEmitter = new vscode.EventEmitter(); + + const postedMessages: unknown[] = []; + let visible = false; + + const view: vscode.WebviewView = { + viewType, + webview: { + options: { enableScripts: false, localResourceRoots: [] }, + html: "", + cspSource: "mock-csp", + onDidReceiveMessage: messageEmitter.event, + postMessage: (msg: unknown) => { + postedMessages.push(msg); + return Promise.resolve(true); + }, + asWebviewUri: (uri: vscode.Uri) => uri, + }, + 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: { 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/harness.ts b/test/unit/webviews/workspaces/harness.ts index 556ed09a71..75a9541b75 100644 --- a/test/unit/webviews/workspaces/harness.ts +++ b/test/unit/webviews/workspaces/harness.ts @@ -7,6 +7,7 @@ import { WorkspaceStore, type PollOptions } from "@/webviews/workspaces/store"; import { createMockLogger, createMockUser, + createMockWebviewView, flushPromises, MockWorkspacesClient, TestSessionStore, @@ -98,32 +99,9 @@ export function createPanel() { }); disposables.push(provider); - let posted: Array<{ type: string; data?: unknown }> = []; - const messageEmitter = new vscode.EventEmitter(); - const visibilityEmitter = new vscode.EventEmitter(); - // Resolved while hidden so tests can stage responses before the first fetch. - let visible = false; - - const view: vscode.WebviewView = { - viewType: WorkspacesPanelProvider.viewType, - webview: { - options: { enableScripts: false, localResourceRoots: [] }, - html: "", - cspSource: "mock-csp", - onDidReceiveMessage: messageEmitter.event, - postMessage: (msg: unknown) => { - posted.push(msg as { type: string; data?: unknown }); - return Promise.resolve(true); - }, - asWebviewUri: (uri: vscode.Uri) => uri, - }, - get visible() { - return visible; - }, - show: vi.fn(), - onDidChangeVisibility: visibilityEmitter.event, - onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), - }; + const { view, hooks } = createMockWebviewView( + WorkspacesPanelProvider.viewType, + ); provider.resolveWebviewView( view, @@ -131,19 +109,14 @@ export function createPanel() { new vscode.CancellationTokenSource().token, ); - const setVisible = (next: boolean) => { - visible = next; - visibilityEmitter.fire(); - }; - return { ...base, provider, view, openWorkspace, - setVisible, + setVisible: hooks.setVisible, show: async () => { - setVisible(true); + hooks.setVisible(true); await base.store.settled; }, /** Send a command from the webview and wait for its handler. */ @@ -151,22 +124,22 @@ export function createPanel() { def: CommandDef

, ...args: P extends void ? [] : [params: P] ) => { - messageEmitter.fire({ method: def.method, params: args[0] }); + hooks.sendFromWebview({ method: def.method, params: args[0] }); await flushPromises(); await base.store.settled; }, /** Send anything the webview could post, valid or not. */ sendRaw: async (message: unknown) => { - messageEmitter.fire(message); + hooks.sendFromWebview(message); await flushPromises(); }, /** Every state update pushed to the webview, oldest first. */ pushedUpdates: () => - posted - .filter((message) => message.type === "stateUpdated") - .map((message) => message.data as WorkspacesUpdate), - clearPushes: () => { - posted = []; - }, + hooks.postedMessages + .filter( + (message) => (message as { type?: string }).type === "stateUpdated", + ) + .map((message) => (message as { data: WorkspacesUpdate }).data), + clearPushes: () => hooks.clearPostedMessages(), }; } diff --git a/test/unit/workspace/agentMetadataTracker.test.ts b/test/unit/workspace/agentMetadataTracker.test.ts index fe3f0c9ccc..b89f55049b 100644 --- a/test/unit/workspace/agentMetadataTracker.test.ts +++ b/test/unit/workspace/agentMetadataTracker.test.ts @@ -20,9 +20,10 @@ const REPORTED = { metadata: [agentMetadata()], error: null, loading: false }; function setup() { const client = new MockWorkspacesClient(); - const tracker = new AgentMetadataTracker(client as unknown as CoderApi, { - lingerMs: LINGER_MS, - }); + const tracker = new AgentMetadataTracker( + client as unknown as CoderApi, + LINGER_MS, + ); const reports: AgentMetadataMap[] = []; tracker.onDidChange((metadata) => reports.push(metadata)); return {