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..c3fbd6bb6f 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,21 +303,34 @@ 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, workspacesPanelProvider, { webviewOptions: { retainContextWhenHidden: true } }, ), - secretsManager.onDidChangeCurrentDeployment(() => - workspacesPanelProvider.refresh(), - ), ); } diff --git a/src/webviews/workspaces/panelProvider.ts b/src/webviews/workspaces/panelProvider.ts new file mode 100644 index 0000000000..90efd37314 --- /dev/null +++ b/src/webviews/workspaces/panelProvider.ts @@ -0,0 +1,213 @@ +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"; + + 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, { + 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) { + Object.assign(this, options); + } + + 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..bdf3248505 --- /dev/null +++ b/src/webviews/workspaces/store.ts @@ -0,0 +1,325 @@ +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 { + 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..f01bed372f --- /dev/null +++ b/src/workspace/agentMetadataTracker.ts @@ -0,0 +1,179 @@ +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, +}; + +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(); + + public readonly onDidChange = this.changeEmitter.event; + + private disposed = false; + + constructor( + private readonly client: CoderApi, + /** 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 { + 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.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..0ee045a459 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(); + } } /** @@ -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/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/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 new file mode 100644 index 0000000000..75a9541b75 --- /dev/null +++ b/test/unit/webviews/workspaces/harness.ts @@ -0,0 +1,145 @@ +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, + createMockWebviewView, + 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); + + const { view, hooks } = createMockWebviewView( + WorkspacesPanelProvider.viewType, + ); + + provider.resolveWebviewView( + view, + {} as vscode.WebviewViewResolveContext, + new vscode.CancellationTokenSource().token, + ); + + return { + ...base, + provider, + view, + openWorkspace, + setVisible: hooks.setVisible, + show: async () => { + hooks.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] + ) => { + 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) => { + hooks.sendFromWebview(message); + await flushPromises(); + }, + /** Every state update pushed to the webview, oldest first. */ + pushedUpdates: () => + 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/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..b89f55049b --- /dev/null +++ b/test/unit/workspace/agentMetadataTracker.test.ts @@ -0,0 +1,227 @@ +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, + 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",