From d8eb18ab02c5ec3baa4138416c85fe3bbab1de45 Mon Sep 17 00:00:00 2001 From: Marcos Navarro Date: Sat, 16 Nov 2024 00:56:21 +0900 Subject: [PATCH 1/6] Remove the bus utility and its dependency since it is not needed. Re-organized that logic to methods and moved over some of it to PublisherState --- extensions/vscode/package-lock.json | 6 - extensions/vscode/package.json | 1 - extensions/vscode/src/bus.ts | 53 ------ extensions/vscode/src/state.ts | 69 +++++-- extensions/vscode/src/views/homeView.ts | 227 ++++++++---------------- 5 files changed, 122 insertions(+), 234 deletions(-) delete mode 100644 extensions/vscode/src/bus.ts diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json index 4c244d8a0..45429d1aa 100644 --- a/extensions/vscode/package-lock.json +++ b/extensions/vscode/package-lock.json @@ -9,7 +9,6 @@ "version": "99.0.0", "license": "MIT", "dependencies": { - "@hypersphere/omnibus": "0.1.6", "@vscode/codicons": "^0.0.36", "async-mutex": "^0.5.0", "axios": "^1.7.4", @@ -555,11 +554,6 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, - "node_modules/@hypersphere/omnibus": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@hypersphere/omnibus/-/omnibus-0.1.6.tgz", - "integrity": "sha512-agZuKyhdW0n1JoLYZUuA6Du1QoQn39/LapFgRtbJs7fyRM62C9O2PWISHUCwAKnC1Splshpd8glQgx5pA2zkCg==" - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 5e8d03668..248b4135c 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -538,7 +538,6 @@ "vitest": "^2.1.1" }, "dependencies": { - "@hypersphere/omnibus": "0.1.6", "@vscode/codicons": "^0.0.36", "async-mutex": "^0.5.0", "axios": "^1.7.4", diff --git a/extensions/vscode/src/bus.ts b/extensions/vscode/src/bus.ts deleted file mode 100644 index 0fca39e3d..000000000 --- a/extensions/vscode/src/bus.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (C) 2024 by Posit Software, PBC. - -import { Omnibus, args } from "@hypersphere/omnibus"; -import { - Configuration, - ConfigurationError, - ContentRecord, - PreContentRecord, -} from "src/api"; - -export const bus = Omnibus.builder() - // activeContentRecordChanged: triggered if contentRecord name or value has changed - .register( - "activeContentRecordChanged", - args(), - ) - // activeConfigurationChanged: triggered if configuration name or value has changed - .register( - "activeConfigChanged", - args(), - ) - // requestActive*: simple events which will cause an Active*Change event to be sent back out. - .register("requestActiveConfig", args()) - .register("requestActiveContentRecord", args()) - .register("refreshCredentials", args()) - - .build(); - -// Setup message logging -// bus.on( -// "activeContentRecordChanged", -// (msg: ContentRecord | PreContentRecord | undefined) => { -// console.debug( -// `\nbus trace: activeContentRecordChanged: ${JSON.stringify(msg)}\n`, -// ); -// }, -// ); -// bus.on("activeConfigChanged", (msg: Configuration | undefined) => { -// console.debug(`\nbus trace: activeConfigChanged: ${JSON.stringify(msg)}\n`); -// }); -// bus.on("requestActiveConfig", () => { -// console.debug(`\nbus trace: requestActiveConfig`); -// }); -// bus.on("requestActiveContentRecord", () => { -// console.debug(`\nbus trace: requestActiveContentRecord`); -// }); -// bus.on("refreshCredentials", () => { -// console.debug(`\nbus trace: refreshCredentials`); -// }); - -export const useBus = () => { - return bus; -}; diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index 62231bf69..14d9c516f 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -14,9 +14,13 @@ import { useApi, } from "src/api"; import { normalizeURL } from "src/utils/url"; +import { showProgress } from "src/utils/progress"; +import { + getStatusFromError, + getSummaryStringFromError, +} from "src/utils/errors"; import { DeploymentSelector, SelectionState } from "src/types/shared"; -import { LocalState } from "./constants"; -import { getStatusFromError, getSummaryStringFromError } from "./utils/errors"; +import { LocalState, Views } from "./constants"; function findContentRecord< T extends ContentRecord | PreContentRecord | PreContentRecordWithConfig, @@ -191,14 +195,27 @@ export class PublisherState implements Disposable { } async refreshContentRecords() { - const api = await useApi(); - const response = await api.contentRecords.getAll(".", { recursive: true }); + try { + await showProgress("Refreshing Deployments", Views.HomeView, async () => { + const api = await useApi(); + const response = await api.contentRecords.getAll(".", { + recursive: true, + }); - // Currently we filter out any Content Records in error - this.contentRecords = response.data.filter( - (r): r is ContentRecord | PreContentRecord | PreContentRecordWithConfig => - !isContentRecordError(r), - ); + // Currently we filter out any Content Records in error + this.contentRecords = response.data.filter( + ( + r, + ): r is + | ContentRecord + | PreContentRecord + | PreContentRecordWithConfig => !isContentRecordError(r), + ); + }); + } catch (error: unknown) { + const summary = getSummaryStringFromError("refreshContentRecords", error); + window.showErrorMessage(summary); + } } findContentRecord(name: string, projectDir: string) { @@ -210,10 +227,22 @@ export class PublisherState implements Disposable { } async refreshConfigurations() { - const api = await useApi(); - const response = await api.configurations.getAll(".", { recursive: true }); - - this.configurations = response.data; + try { + await showProgress( + "Refreshing Configurations", + Views.HomeView, + async () => { + const api = await useApi(); + const response = await api.configurations.getAll(".", { + recursive: true, + }); + this.configurations = response.data; + }, + ); + } catch (error: unknown) { + const summary = getSummaryStringFromError("refreshConfigurations", error); + window.showErrorMessage(summary); + } } get validConfigs(): Configuration[] { @@ -239,10 +268,16 @@ export class PublisherState implements Disposable { } async refreshCredentials() { - const api = await useApi(); - const response = await api.credentials.list(); - - this.credentials = response.data; + try { + await showProgress("Refreshing Credentials", Views.HomeView, async () => { + const api = await useApi(); + const response = await api.credentials.list(); + this.credentials = response.data; + }); + } catch (error: unknown) { + const summary = getSummaryStringFromError("refreshCredentials", error); + window.showErrorMessage(summary); + } } findCredential(name: string) { diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index fcbb14591..a2893ae16 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -37,7 +37,6 @@ import { AllContentRecordTypes, EnvironmentConfig, } from "src/api"; -import { useBus } from "src/bus"; import { EventStream } from "src/events"; import { getPythonInterpreterPath } from "../utils/config"; import { getSummaryStringFromError } from "src/utils/errors"; @@ -122,85 +121,6 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { this.extensionUri = this.context.extensionUri; this.webviewConduit = new WebviewConduit(); - - // if someone needs a refresh of any active params, - // we are here to service that request! - useBus().on("refreshCredentials", async () => { - await this.refreshCredentialData(); - this.updateWebViewViewCredentials(); - }); - useBus().on("requestActiveConfig", async () => { - useBus().trigger( - "activeConfigChanged", - await this.state.getSelectedConfiguration(), - ); - }); - useBus().on("requestActiveContentRecord", async () => { - useBus().trigger( - "activeContentRecordChanged", - await this.state.getSelectedContentRecord(), - ); - }); - - useBus().on("activeContentRecordChanged", (contentRecord) => { - this.contentRecordWatchers?.dispose(); - - this.contentRecordWatchers = new ContentRecordWatcherManager( - contentRecord, - ); - - this.contentRecordWatchers.contentRecord?.onDidChange( - this.updateServerEnvironment, - this, - ); - }); - - useBus().on( - "activeConfigChanged", - (cfg: Configuration | ConfigurationError | undefined) => { - this.sendRefreshedFilesLists(); - this.updateServerEnvironment(); - this.refreshPythonPackages(); - this.refreshRPackages(); - - this.configWatchers?.dispose(); - if (cfg && isConfigurationError(cfg)) { - return; - } - this.configWatchers = new ConfigWatcherManager(cfg); - - this.configWatchers.configFile?.onDidChange(() => { - this.debounceSendRefreshedFilesLists(); - this.updateServerEnvironment(); - }, this); - - this.configWatchers.pythonPackageFile?.onDidCreate( - this.debounceRefreshPythonPackages, - this, - ); - this.configWatchers.pythonPackageFile?.onDidChange( - this.debounceRefreshPythonPackages, - this, - ); - this.configWatchers.pythonPackageFile?.onDidDelete( - this.debounceRefreshPythonPackages, - this, - ); - - this.configWatchers.rPackageFile?.onDidCreate( - this.debounceRefreshRPackages, - this, - ); - this.configWatchers.rPackageFile?.onDidChange( - this.debounceRefreshRPackages, - this, - ); - this.configWatchers.rPackageFile?.onDidDelete( - this.debounceRefreshRPackages, - this, - ); - }, - ); } /** * Dispatch messages passed from the webview to the handling code @@ -236,7 +156,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { case WebviewToHostMessageType.REQUEST_FILES_LISTS: return this.debounceSendRefreshedFilesLists(); case WebviewToHostMessageType.REQUEST_CREDENTIALS: - return await this.onRequestCredentials(); + return await this.refreshCredentials(); case WebviewToHostMessageType.INCLUDE_FILE: return this.updateFileList(msg.content.path, FileAction.INCLUDE); case WebviewToHostMessageType.EXCLUDE_FILE: @@ -410,60 +330,67 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { }); } - private async refreshContentRecordData() { - try { - await showProgress( - "Refreshing Deployments", - Views.HomeView, - async () => await this.state.refreshContentRecords(), - ); - } catch (error: unknown) { - const summary = getSummaryStringFromError( - "refreshContentRecordData::contentRecords.getAll", - error, - ); - window.showErrorMessage(summary); - return; - } + private async refreshCredentials() { + await this.state.refreshCredentials(); + return this.updateWebViewViewCredentials(); } - private async refreshConfigurationData() { - try { - await showProgress( - "Refreshing Configurations", - Views.HomeView, - async () => await this.state.refreshConfigurations(), - ); - } catch (error: unknown) { - const summary = getSummaryStringFromError( - "Internal Error: refreshConfigurationData::configurations.getAll", - error, - ); - window.showErrorMessage(summary); + private async refreshActiveConfig() { + const cfg = await this.state.getSelectedConfiguration(); + + this.sendRefreshedFilesLists(); + this.updateServerEnvironment(); + this.refreshPythonPackages(); + this.refreshRPackages(); + + this.configWatchers?.dispose(); + if (cfg && isConfigurationError(cfg)) { return; } - } + this.configWatchers = new ConfigWatcherManager(cfg); - private async onRequestCredentials() { - await this.refreshCredentialData(); - return this.updateWebViewViewCredentials(); + this.configWatchers.configFile?.onDidChange(() => { + this.debounceSendRefreshedFilesLists(); + this.updateServerEnvironment(); + }, this); + + this.configWatchers.pythonPackageFile?.onDidCreate( + this.debounceRefreshPythonPackages, + this, + ); + this.configWatchers.pythonPackageFile?.onDidChange( + this.debounceRefreshPythonPackages, + this, + ); + this.configWatchers.pythonPackageFile?.onDidDelete( + this.debounceRefreshPythonPackages, + this, + ); + + this.configWatchers.rPackageFile?.onDidCreate( + this.debounceRefreshRPackages, + this, + ); + this.configWatchers.rPackageFile?.onDidChange( + this.debounceRefreshRPackages, + this, + ); + this.configWatchers.rPackageFile?.onDidDelete( + this.debounceRefreshRPackages, + this, + ); } - private async refreshCredentialData() { - try { - await showProgress( - "Refreshing Credentials", - Views.HomeView, - async () => await this.state.refreshCredentials(), - ); - } catch (error: unknown) { - const summary = getSummaryStringFromError( - "Internal Error: refreshCredentialData::credentials.list", - error, - ); - window.showErrorMessage(summary); - return; - } + private async refreshActiveContentRecord() { + const contentRecord = await this.state.getSelectedContentRecord(); + this.contentRecordWatchers?.dispose(); + + this.contentRecordWatchers = new ContentRecordWatcherManager(contentRecord); + + this.contentRecordWatchers.contentRecord?.onDidChange( + this.updateServerEnvironment, + this, + ); } private updateWebViewViewContentRecords( @@ -521,14 +448,8 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { version: "v1", }); } - useBus().trigger( - "activeContentRecordChanged", - await this.state.getSelectedContentRecord(), - ); - useBus().trigger( - "activeConfigChanged", - await this.state.getSelectedConfiguration(), - ); + this.refreshActiveContentRecord(); + this.refreshActiveConfig(); } public debounceRefreshPythonPackages = debounce( @@ -954,7 +875,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { this.propagateDeploymentSelection(deploymentSelector); // Credentials aren't auto-refreshed, so we have to trigger it ourselves. if (refreshCredentials) { - useBus().trigger("refreshCredentials", undefined); + this.refreshCredentials(); } return { deploymentName: deploymentObjects.contentRecord.deploymentName, @@ -1093,7 +1014,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { public addCredential = async (startingServerUrl?: string) => { const credential = await newCredential(Views.HomeView, startingServerUrl); if (credential) { - useBus().trigger("refreshCredentials", undefined); + this.refreshCredentials(); } }; @@ -1120,7 +1041,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const summary = getSummaryStringFromError("credential::delete", error); window.showInformationMessage(summary); } - useBus().trigger("refreshCredentials", undefined); + this.refreshCredentials(); }; private showPublishingLog() { @@ -1461,11 +1382,11 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { ) => { try { await showProgress("Refreshing Data", Views.HomeView, async () => { - const apis: Promise[] = [this.refreshCredentialData()]; + const apis: Promise[] = [this.state.refreshCredentials()]; if (forceAll) { // we have been told to refresh everything - apis.push(this.refreshContentRecordData()); - apis.push(this.refreshConfigurationData()); + apis.push(this.state.refreshContentRecords()); + apis.push(this.state.refreshConfigurations()); } return await Promise.all(apis); }); @@ -1477,8 +1398,6 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { window.showInformationMessage(summary); return; } - const selectedContentRecord = await this.state.getSelectedContentRecord(); - const selectedConfig = await this.state.getSelectedConfiguration(); const selectionState = includeSavedState ? this.state.getSelection() @@ -1487,8 +1406,8 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { this.updateWebViewViewConfigurations(); this.updateWebViewViewContentRecords(selectionState || null); if (includeSavedState && selectionState) { - useBus().trigger("activeContentRecordChanged", selectedContentRecord); - useBus().trigger("activeConfigChanged", selectedConfig); + this.refreshActiveContentRecord(); + this.refreshActiveConfig(); } }; @@ -1497,12 +1416,9 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { return throttleWithLastPending( this.refreshContentRecordsMutex, async () => { - await this.refreshContentRecordData(); + await this.state.refreshContentRecords(); this.updateWebViewViewContentRecords(); - useBus().trigger( - "activeContentRecordChanged", - await this.state.getSelectedContentRecord(), - ); + this.refreshActiveContentRecord(); }, ); }; @@ -1512,12 +1428,9 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { return throttleWithLastPending( this.refreshConfigurationsMutex, async () => { - await this.refreshConfigurationData(); + await this.state.refreshConfigurations(); this.updateWebViewViewConfigurations(); - useBus().trigger( - "activeConfigChanged", - await this.state.getSelectedConfiguration(), - ); + this.refreshActiveConfig(); }, ); }; @@ -1967,7 +1880,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { this.context.subscriptions.push( commands.registerCommand(Commands.HomeView.RefreshCredentials, () => - useBus().trigger("refreshCredentials", undefined), + this.refreshCredentials(), ), commands.registerCommand( Commands.HomeView.AddCredential, From 11f92e31b62572d47e77cd3e3f293d6f288cd33b Mon Sep 17 00:00:00 2001 From: Marcos Navarro Date: Tue, 19 Nov 2024 22:15:59 +0900 Subject: [PATCH 2/6] Add fishery as dependency to create factories for testing purposes. Create some factory methods and utilitary vscode mocks --- extensions/vscode/package-lock.json | 16 ++++ extensions/vscode/package.json | 1 + .../src/test/unit-test-utils/factories.ts | 85 +++++++++++++++++++ .../src/test/unit-test-utils/vscode-mocks.ts | 83 ++++++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 extensions/vscode/src/test/unit-test-utils/factories.ts create mode 100644 extensions/vscode/src/test/unit-test-utils/vscode-mocks.ts diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json index 45429d1aa..4abd36478 100644 --- a/extensions/vscode/package-lock.json +++ b/extensions/vscode/package-lock.json @@ -36,6 +36,7 @@ "esbuild": "^0.21.4", "eslint": "^8.50.0", "eslint-config-prettier": "^9.1.0", + "fishery": "^2.2.2", "glob": "^10.3.3", "mocha": "^10.2.0", "typescript": "^5.2.2", @@ -2636,6 +2637,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fishery": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/fishery/-/fishery-2.2.2.tgz", + "integrity": "sha512-jeU0nDhPHJkupmjX+r9niKgVMTBDB8X+U/pktoGHAiWOSyNlMd0HhmqnjrpjUOCDPJYaSSu4Ze16h6dZOKSp2w==", + "dev": true, + "dependencies": { + "lodash.mergewith": "^4.6.2" + } + }, "node_modules/flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", @@ -3347,6 +3357,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 248b4135c..3976d1078 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -532,6 +532,7 @@ "esbuild": "^0.21.4", "eslint": "^8.50.0", "eslint-config-prettier": "^9.1.0", + "fishery": "^2.2.2", "glob": "^10.3.3", "mocha": "^10.2.0", "typescript": "^5.2.2", diff --git a/extensions/vscode/src/test/unit-test-utils/factories.ts b/extensions/vscode/src/test/unit-test-utils/factories.ts new file mode 100644 index 000000000..57633a202 --- /dev/null +++ b/extensions/vscode/src/test/unit-test-utils/factories.ts @@ -0,0 +1,85 @@ +import { Factory } from "fishery"; +import { + PreContentRecord, + ContentRecord, + ServerType, + ContentRecordState, +} from "src/api/types/contentRecords"; +import { ContentType, Configuration } from "src/api/types/configurations"; +import { DeploymentSelectorState } from "src/types/shared"; + +export const selectionStateFactory = Factory.define( + ({ sequence }) => ({ + version: "v1", + projectDir: `report-GUD${sequence}`, + deploymentName: `report ${sequence}`, + deploymentPath: `report/path/${sequence}`, + }), +); + +export const configurationFactory = Factory.define( + ({ sequence }) => ({ + configuration: { + $schema: "test-schema-url", + type: ContentType.RMD, + validate: true, + configurationName: "", + projectDir: "", + }, + projectDir: `report-GUD${sequence}`, + configurationName: `configuration-GUD${sequence}`, + configurationPath: `report/path/configuration-${sequence}`, + configurationRelPath: `report/path/configuration-${sequence}`, + }), +); + +export const preContentRecordFactory = Factory.define( + ({ sequence }) => ({ + $schema: "test-schema-url", + serverType: ServerType.CONNECT, + serverUrl: `https://connect-test-${sequence}/connect`, + saveName: `Report ${sequence}`, + createdAt: new Date().toISOString(), + configurationName: `report-GUD${sequence}`, + type: ContentType.RMD, + deploymentError: null, + state: ContentRecordState.NEW, + projectDir: `report-GUD${sequence}`, + deploymentName: `report ${sequence}`, + deploymentPath: `report/path/${sequence}`, + }), +); + +export const contentRecordFactory = Factory.define( + ({ sequence }) => ({ + $schema: "test-schema-url", + id: `GUD${sequence}`, + bundleId: `XYZ${sequence}`, + bundleUrl: `XYZ${sequence}`, + dashboardUrl: `https://connect-test-${sequence}/connect`, + directUrl: `https://connect-test-${sequence}/content/XYZ${sequence}`, + logsUrl: `https://connect-test-${sequence}/connect/#/apps/XYZ${sequence}/output`, + files: [], + serverType: ServerType.CONNECT, + serverUrl: `https://connect-test-${sequence}/connect`, + saveName: `Report ${sequence}`, + createdAt: new Date().toISOString(), + deployedAt: new Date().toISOString(), + configurationName: `report-GUD${sequence}`, + type: ContentType.RMD, + deploymentError: null, + state: ContentRecordState.DEPLOYED, + projectDir: `report-GUD${sequence}`, + deploymentName: `report ${sequence}`, + deploymentPath: `report/path/${sequence}`, + configuration: { + $schema: "test-schema-url", + type: ContentType.RMD, + validate: true, + configurationName: "", + projectDir: "", + }, + configurationPath: `report/path/configuration-${sequence}`, + configurationRelPath: `report/path/configuration-${sequence}`, + }), +); diff --git a/extensions/vscode/src/test/unit-test-utils/vscode-mocks.ts b/extensions/vscode/src/test/unit-test-utils/vscode-mocks.ts new file mode 100644 index 000000000..0199fc481 --- /dev/null +++ b/extensions/vscode/src/test/unit-test-utils/vscode-mocks.ts @@ -0,0 +1,83 @@ +// Copyright (C) 2024 by Posit Software, PBC. + +import { vi } from "vitest"; + +/** + * Mock class to be used as vscode WorkspaceState. + * Accepts an initial object as state when instantiated. + * Implements all the methods which are also spy functions + * to which it is possible to assert calls. + * + * IMPORTANT: + * Rarely you'll need to create instances of this mock directly. + * Very likely you'll be better of with the factory function `mkExtensionContextStateMock` + * to get a vscode mock instance of ExtensionContext. + * + * @example + * const mockWs = new mockWorkspaceState({}); + * // some test operation here ... + * expect(mockWs.get).toHaveBeenCalledWith("something"); + */ +export class mockWorkspaceState { + private state: Record; + + constructor(state: Record) { + this.state = state; + } + + readonly keys = vi.fn((): string[] => { + return Object.keys(this.state); + }); + + readonly get = vi.fn((key: string, defaultValue?: T) => { + const v = this.state[key]; + if (v) { + return v; + } + return defaultValue || undefined; + }); + + readonly update = vi.fn((key: string, value: any): Thenable => { + this.state[key] = value; + return Promise.resolve(); + }); +} + +/** + * Mock class to be used as vscode ExtensionContext. + * Accepts an initial `mockWorkspaceState`. + * + * IMPORTANT: + * Rarely you'll need to create instances of this mock directly. + * Very likely you'll be better of with the factory function `mkExtensionContextStateMock` + * to get a vscode mock instance of ExtensionContext. + */ +export class mockExtensionContext { + readonly workspaceState: mockWorkspaceState; + + constructor(workspaceState: mockWorkspaceState) { + this.workspaceState = workspaceState; + } +} + +/** + * Factory function to generate a vscode ExtensionContext mock class. + * @param initState Object to be used as the initial state + * @returns {Object} Returns an object with mockExtensionContext as mockContext and mockWorkspaceState as mockWorkspace that can be used to assert method calls. + * + * @example + * const { mockWorkspace, mockContext } = new mkExtensionContextStateMock({}); + * const publisherState = new PublisherState(mockContext); + * publisherState.updateSelection(xyz) + * expect(mockWorkspace.update).toHaveBeenCalledWith("something"); + */ +export const mkExtensionContextStateMock = ( + initState: Record, +) => { + const mockWorkspace = new mockWorkspaceState(initState); + const mockContext = new mockExtensionContext(mockWorkspace); + return { + mockWorkspace, + mockContext, + }; +}; From 9384db8dcf9ab39214135e227bacb7cbb21183e9 Mon Sep 17 00:00:00 2001 From: Marcos Navarro Date: Tue, 19 Nov 2024 22:16:36 +0900 Subject: [PATCH 3/6] Add the current progress of tests for PublisherState --- extensions/vscode/src/state.test.ts | 376 ++++++++++++++++++++++++++ extensions/vscode/src/state.ts | 14 +- extensions/vscode/src/types/shared.ts | 10 +- 3 files changed, 392 insertions(+), 8 deletions(-) create mode 100644 extensions/vscode/src/state.test.ts diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts new file mode 100644 index 000000000..6c3416d74 --- /dev/null +++ b/extensions/vscode/src/state.test.ts @@ -0,0 +1,376 @@ +// Copyright (C) 2024 by Posit Software, PBC. + +import { afterEach, describe, expect, test, vi } from "vitest"; +import { window } from "vscode"; +import { AxiosError, AxiosHeaders } from "axios"; +import { DeploymentSelectorState } from "src/types/shared"; +import { + selectionStateFactory, + preContentRecordFactory, + configurationFactory, +} from "src/test/unit-test-utils/factories"; +import { mkExtensionContextStateMock } from "src/test/unit-test-utils/vscode-mocks"; +import { LocalState } from "./constants"; +import { PublisherState } from "./state"; +import { PreContentRecord } from "src/api"; + +class mockApiClient { + readonly contentRecords = { + get: vi.fn(), + getAll: vi.fn(), + }; + + readonly configurations = { + get: vi.fn(), + getAll: vi.fn(), + }; + + readonly credentials = { + list: vi.fn(), + }; +} + +const mockClient = new mockApiClient(); + +vi.mock("src/api", async (importOriginal) => { + return { + ...(await importOriginal()), + useApi: () => Promise.resolve(mockClient), + }; +}); + +vi.mock("vscode", () => { + // mock Disposable + const disposableMock = vi.fn(); + disposableMock.prototype.dispose = vi.fn(); + + // mock window + const windowMock = { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + }; + + return { + Disposable: disposableMock, + window: windowMock, + }; +}); + +describe("PublisherState", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("new instance", () => { + const { mockContext } = mkExtensionContextStateMock({}); + const publisherState = new PublisherState(mockContext); + expect(publisherState.contentRecords).toEqual([]); + expect(publisherState.configurations).toEqual([]); + expect(publisherState.credentials).toEqual([]); + }); + + test("get and update deployment selection", async () => { + const newState: DeploymentSelectorState = selectionStateFactory.build(); + const { mockWorkspace, mockContext } = mkExtensionContextStateMock({}); + const publisherState = new PublisherState(mockContext); + + let currentSelection = publisherState.getSelection(); + expect(currentSelection).toEqual(undefined); + expect(mockWorkspace.get).toHaveBeenCalledWith( + LocalState.LastSelectionState, + null, + ); + + await publisherState.updateSelection(newState); + expect(mockWorkspace.update).toHaveBeenCalledWith( + LocalState.LastSelectionState, + newState, + ); + + currentSelection = publisherState.getSelection(); + expect(currentSelection).toEqual({ + projectDir: newState.projectDir, + deploymentName: newState.deploymentName, + deploymentPath: newState.deploymentPath, + }); + }); + + describe("getSelectedContentRecord", () => { + test("finding records and cache handling", async () => { + const initialState: DeploymentSelectorState = + selectionStateFactory.build(); + const updatedState: DeploymentSelectorState = + selectionStateFactory.build(); + + const { mockContext } = mkExtensionContextStateMock({}); + const publisherState = new PublisherState(mockContext); + + let currentSelection = await publisherState.getSelectedContentRecord(); + expect(currentSelection).toEqual(undefined); + expect(mockClient.contentRecords.get).not.toHaveBeenCalled(); + + // setup fake response from api client, note path must be the same between selection state and content record + const firstGetResponseData: PreContentRecord = + preContentRecordFactory.build({ + deploymentPath: initialState.deploymentPath, + }); + mockClient.contentRecords.get.mockResolvedValue({ + data: firstGetResponseData, + }); + + // selection has something now + await publisherState.updateSelection(initialState); + + currentSelection = await publisherState.getSelectedContentRecord(); + expect(mockClient.contentRecords.get).toHaveBeenCalledTimes(1); + expect(mockClient.contentRecords.get).toHaveBeenCalledWith( + initialState.deploymentName, + initialState.projectDir, + ); + expect(currentSelection).toEqual(firstGetResponseData); + expect(publisherState.contentRecords).toEqual([firstGetResponseData]); + + // second time calls from cache + currentSelection = await publisherState.getSelectedContentRecord(); + + // Only the previous call is registered + expect(mockClient.contentRecords.get).toHaveBeenCalledTimes(1); + expect(currentSelection).toEqual(firstGetResponseData); + expect(publisherState.contentRecords).toEqual([firstGetResponseData]); + + // setup a second fake response from api client + const secondGetResponseData: PreContentRecord = + preContentRecordFactory.build(); + mockClient.contentRecords.get.mockResolvedValue({ + data: secondGetResponseData, + }); + + // selection has something different this time + await publisherState.updateSelection(updatedState); + + // third time will get updated record + currentSelection = await publisherState.getSelectedContentRecord(); + + // Two API calls were triggered, each for every different + expect(mockClient.contentRecords.get).toHaveBeenCalledTimes(2); + expect(currentSelection).toEqual(secondGetResponseData); + + // Cache now keeps the different records + expect(publisherState.contentRecords).toEqual([ + firstGetResponseData, + secondGetResponseData, + ]); + }); + + test("error responses from API", async () => { + const initialState: DeploymentSelectorState = + selectionStateFactory.build(); + + const { mockContext } = mkExtensionContextStateMock({}); + const publisherState = new PublisherState(mockContext); + + // setup fake 404 error from api client + const axiosErr = new AxiosError(); + axiosErr.response = { + data: "", + status: 404, + statusText: "404", + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + mockClient.contentRecords.get.mockRejectedValue(axiosErr); + + // set an initial state so it tries to pull from API + await publisherState.updateSelection(initialState); + + let currentSelection = await publisherState.getSelectedContentRecord(); + expect(mockClient.contentRecords.get).toHaveBeenCalledTimes(1); + expect(mockClient.contentRecords.get).toHaveBeenCalledWith( + initialState.deploymentName, + initialState.projectDir, + ); + + // 404 errors are just ignored + expect(currentSelection).toEqual(undefined); + expect(publisherState.contentRecords).toEqual([]); + expect(window.showInformationMessage).not.toHaveBeenCalled(); + + // NOT 404 errors are shown + axiosErr.response = { + data: "custom test error", + status: 401, + statusText: "401", + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + mockClient.contentRecords.get.mockRejectedValue(axiosErr); + + currentSelection = await publisherState.getSelectedContentRecord(); + expect(mockClient.contentRecords.get).toHaveBeenCalledTimes(2); + expect(mockClient.contentRecords.get).toHaveBeenCalledWith( + initialState.deploymentName, + initialState.projectDir, + ); + + // This error is propagated up now + expect(currentSelection).toEqual(undefined); + expect(publisherState.contentRecords).toEqual([]); + expect(window.showInformationMessage).toHaveBeenCalledWith( + "Unable to retrieve deployment record: custom test error", + ); + }); + }); + + describe("getSelectedConfiguration", () => { + test("finding configuration and cache handling", async () => { + const contentRecordState: DeploymentSelectorState = + selectionStateFactory.build(); + + const { mockContext } = mkExtensionContextStateMock({}); + const publisherState = new PublisherState(mockContext); + + // No config get due to no content record set + let currentConfig = await publisherState.getSelectedConfiguration(); + expect(currentConfig).toEqual(undefined); + expect(mockClient.configurations.get).not.toHaveBeenCalled(); + + // setup existing content record in cache + const contentRecord = preContentRecordFactory.build({ + deploymentPath: contentRecordState.deploymentPath, + }); + publisherState.contentRecords.push(contentRecord); + + // setup fake config API response, note config name and project dir must be the same between content record and config + const config = configurationFactory.build({ + configurationName: contentRecord.configurationName, + projectDir: contentRecord.projectDir, + }); + mockClient.configurations.get.mockResolvedValue({ + data: config, + }); + + // selection has something now + await publisherState.updateSelection(contentRecordState); + + currentConfig = await publisherState.getSelectedConfiguration(); + expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); + expect(mockClient.configurations.get).toHaveBeenCalledWith( + contentRecord.configurationName, + contentRecord.projectDir, + ); + expect(currentConfig).toEqual(config); + expect(publisherState.configurations).toEqual([config]); + + // second time calls from cache + currentConfig = await publisherState.getSelectedConfiguration(); + + // Only the previous call is registered + expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); + expect(currentConfig).toEqual(config); + expect(publisherState.configurations).toEqual([config]); + + // setup a second content record in cache and it's respective config API response + const secondContentRecordState: DeploymentSelectorState = + selectionStateFactory.build(); + const secondContentRecord = preContentRecordFactory.build({ + deploymentPath: secondContentRecordState.deploymentPath, + }); + publisherState.contentRecords.push(secondContentRecord); + + const secondConfig = configurationFactory.build({ + configurationName: secondContentRecord.configurationName, + projectDir: secondContentRecord.projectDir, + }); + mockClient.configurations.get.mockResolvedValue({ + data: secondConfig, + }); + + // selection has something different this time + await publisherState.updateSelection(secondContentRecordState); + + // third time will get a new configuration + currentConfig = await publisherState.getSelectedConfiguration(); + + // Two API calls were triggered, each for every different + expect(mockClient.configurations.get).toHaveBeenCalledTimes(2); + expect(currentConfig).toEqual(secondConfig); + expect(publisherState.configurations).toEqual([config, secondConfig]); + }); + + test("error responses from API", async () => { + const contentRecordState: DeploymentSelectorState = + selectionStateFactory.build(); + + const { mockContext } = mkExtensionContextStateMock({}); + const publisherState = new PublisherState(mockContext); + + // setup existing content record in cache + const contentRecord = preContentRecordFactory.build({ + deploymentPath: contentRecordState.deploymentPath, + }); + publisherState.contentRecords.push(contentRecord); + + // setup fake 404 error from api client + const axiosErr = new AxiosError(); + axiosErr.response = { + data: "", + status: 404, + statusText: "404", + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + mockClient.configurations.get.mockRejectedValue(axiosErr); + + // set an initial state so it tries to pull from API + await publisherState.updateSelection(contentRecordState); + + let currentConfig = await publisherState.getSelectedConfiguration(); + expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); + expect(mockClient.configurations.get).toHaveBeenCalledWith( + contentRecord.configurationName, + contentRecord.projectDir, + ); + + // 404 errors are just ignored + expect(currentConfig).toEqual(undefined); + expect(publisherState.configurations).toEqual([]); + expect(window.showInformationMessage).not.toHaveBeenCalled(); + + // NOT 404 errors are shown + axiosErr.response = { + data: "custom test error", + status: 401, + statusText: "401", + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + mockClient.contentRecords.get.mockRejectedValue(axiosErr); + + currentConfig = await publisherState.getSelectedConfiguration(); + expect(mockClient.configurations.get).toHaveBeenCalledTimes(2); + expect(mockClient.configurations.get).toHaveBeenCalledWith( + contentRecord.configurationName, + contentRecord.projectDir, + ); + + // This error is propagated up now + expect(currentConfig).toEqual(undefined); + expect(publisherState.configurations).toEqual([]); + expect(window.showInformationMessage).toHaveBeenCalledWith( + "Unable to retrieve deployment configuration: custom test error", + ); + }); + }); + + test.todo("getSelectedConfiguration", () => {}); + + test.todo("refreshContentRecords", () => {}); + + test.todo("refreshConfigurations", () => {}); + + test.todo("validConfigs", () => {}); + + test.todo("configsInError", () => {}); + + test.todo("refreshCredentials", () => {}); +}); diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index 14d9c516f..b5e5aa357 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -1,6 +1,6 @@ // Copyright (C) 2024 by Posit Software, PBC. -import { Disposable, ExtensionContext, window } from "vscode"; +import { Disposable, Memento, window } from "vscode"; import { Configuration, @@ -64,8 +64,16 @@ function findCredentialForContentRecord( ); } +/** + * Local extension context interface containing only what is used by PublisherState + */ +interface extensionContext { + // A memento object that stores state in the context + readonly workspaceState: Memento; +} + export class PublisherState implements Disposable { - private readonly context: ExtensionContext; + private readonly context: extensionContext; contentRecords: Array< ContentRecord | PreContentRecord | PreContentRecordWithConfig @@ -73,7 +81,7 @@ export class PublisherState implements Disposable { configurations: Array = []; credentials: Credential[] = []; - constructor(context: ExtensionContext) { + constructor(context: extensionContext) { this.context = context; } diff --git a/extensions/vscode/src/types/shared.ts b/extensions/vscode/src/types/shared.ts index 8779e8521..8fa50360d 100644 --- a/extensions/vscode/src/types/shared.ts +++ b/extensions/vscode/src/types/shared.ts @@ -18,11 +18,11 @@ export type PublishProcessParams = DeploymentSelector & { configurationName: string; }; -export type SelectionState = - | (DeploymentSelector & { - version: "v1"; - }) - | null; +export type DeploymentSelectorState = DeploymentSelector & { + version: "v1"; +}; + +export type SelectionState = DeploymentSelectorState | null; export type DeploymentObjects = { contentRecord: ContentRecord | PreContentRecord; From cf5ca20ef97afca0ac6237f96b3b53ad67fba5af Mon Sep 17 00:00:00 2001 From: Marcos Navarro Date: Wed, 20 Nov 2024 01:27:13 +0900 Subject: [PATCH 4/6] Lint. Avoid any on mocks --- .../vscode/src/test/unit-test-utils/vscode-mocks.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/vscode/src/test/unit-test-utils/vscode-mocks.ts b/extensions/vscode/src/test/unit-test-utils/vscode-mocks.ts index 0199fc481..aef67c2c8 100644 --- a/extensions/vscode/src/test/unit-test-utils/vscode-mocks.ts +++ b/extensions/vscode/src/test/unit-test-utils/vscode-mocks.ts @@ -19,9 +19,9 @@ import { vi } from "vitest"; * expect(mockWs.get).toHaveBeenCalledWith("something"); */ export class mockWorkspaceState { - private state: Record; + private state: Record; - constructor(state: Record) { + constructor(state: Record) { this.state = state; } @@ -37,7 +37,7 @@ export class mockWorkspaceState { return defaultValue || undefined; }); - readonly update = vi.fn((key: string, value: any): Thenable => { + readonly update = vi.fn((key: string, value: string): Thenable => { this.state[key] = value; return Promise.resolve(); }); @@ -72,7 +72,7 @@ export class mockExtensionContext { * expect(mockWorkspace.update).toHaveBeenCalledWith("something"); */ export const mkExtensionContextStateMock = ( - initState: Record, + initState: Record, ) => { const mockWorkspace = new mockWorkspaceState(initState); const mockContext = new mockExtensionContext(mockWorkspace); From eb3ef4a6390d5ecb3f21e1ea834589e4a4484e9f Mon Sep 17 00:00:00 2001 From: Marcos Navarro Date: Wed, 20 Nov 2024 01:41:05 +0900 Subject: [PATCH 5/6] Missing copyright --- extensions/vscode/src/test/unit-test-utils/factories.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/vscode/src/test/unit-test-utils/factories.ts b/extensions/vscode/src/test/unit-test-utils/factories.ts index 57633a202..5afcc04f4 100644 --- a/extensions/vscode/src/test/unit-test-utils/factories.ts +++ b/extensions/vscode/src/test/unit-test-utils/factories.ts @@ -1,3 +1,5 @@ +// Copyright (C) 2024 by Posit Software, PBC. + import { Factory } from "fishery"; import { PreContentRecord, From 7c9297010c770453b3bd43a9930bb136cbf4802b Mon Sep 17 00:00:00 2001 From: Marcos Navarro Date: Mon, 25 Nov 2024 11:49:54 +0900 Subject: [PATCH 6/6] PublisherState tests feedback: Split error tests into separate units --- extensions/vscode/src/state.test.ts | 243 +++++++++++++++------------- 1 file changed, 133 insertions(+), 110 deletions(-) diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index 6c3416d74..3d38f0d87 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -1,6 +1,6 @@ // Copyright (C) 2024 by Posit Software, PBC. -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { window } from "vscode"; import { AxiosError, AxiosHeaders } from "axios"; import { DeploymentSelectorState } from "src/types/shared"; @@ -109,7 +109,8 @@ describe("PublisherState", () => { expect(currentSelection).toEqual(undefined); expect(mockClient.contentRecords.get).not.toHaveBeenCalled(); - // setup fake response from api client, note path must be the same between selection state and content record + // setup fake response from api client, + // path must be the same between selection state and content record const firstGetResponseData: PreContentRecord = preContentRecordFactory.build({ deploymentPath: initialState.deploymentPath, @@ -162,62 +163,73 @@ describe("PublisherState", () => { ]); }); - test("error responses from API", async () => { - const initialState: DeploymentSelectorState = - selectionStateFactory.build(); + describe("error responses from API", () => { + let publisherState: PublisherState; + let initialState: DeploymentSelectorState; - const { mockContext } = mkExtensionContextStateMock({}); - const publisherState = new PublisherState(mockContext); + beforeEach(() => { + initialState = selectionStateFactory.build(); - // setup fake 404 error from api client - const axiosErr = new AxiosError(); - axiosErr.response = { - data: "", - status: 404, - statusText: "404", - headers: {}, - config: { headers: new AxiosHeaders() }, - }; - mockClient.contentRecords.get.mockRejectedValue(axiosErr); - - // set an initial state so it tries to pull from API - await publisherState.updateSelection(initialState); - - let currentSelection = await publisherState.getSelectedContentRecord(); - expect(mockClient.contentRecords.get).toHaveBeenCalledTimes(1); - expect(mockClient.contentRecords.get).toHaveBeenCalledWith( - initialState.deploymentName, - initialState.projectDir, - ); + const { mockContext } = mkExtensionContextStateMock({}); + publisherState = new PublisherState(mockContext); - // 404 errors are just ignored - expect(currentSelection).toEqual(undefined); - expect(publisherState.contentRecords).toEqual([]); - expect(window.showInformationMessage).not.toHaveBeenCalled(); - - // NOT 404 errors are shown - axiosErr.response = { - data: "custom test error", - status: 401, - statusText: "401", - headers: {}, - config: { headers: new AxiosHeaders() }, - }; - mockClient.contentRecords.get.mockRejectedValue(axiosErr); + // set an initial state so it tries to pull from API + return publisherState.updateSelection(initialState); + }); - currentSelection = await publisherState.getSelectedContentRecord(); - expect(mockClient.contentRecords.get).toHaveBeenCalledTimes(2); - expect(mockClient.contentRecords.get).toHaveBeenCalledWith( - initialState.deploymentName, - initialState.projectDir, - ); + test("404", async () => { + // setup fake 404 error from api client + const axiosErr = new AxiosError(); + axiosErr.response = { + data: "", + status: 404, + statusText: "404", + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + mockClient.contentRecords.get.mockRejectedValue(axiosErr); + + const currentSelection = + await publisherState.getSelectedContentRecord(); + expect(mockClient.contentRecords.get).toHaveBeenCalledTimes(1); + expect(mockClient.contentRecords.get).toHaveBeenCalledWith( + initialState.deploymentName, + initialState.projectDir, + ); + + // 404 errors are just ignored + expect(currentSelection).toEqual(undefined); + expect(publisherState.contentRecords).toEqual([]); + expect(window.showInformationMessage).not.toHaveBeenCalled(); + }); - // This error is propagated up now - expect(currentSelection).toEqual(undefined); - expect(publisherState.contentRecords).toEqual([]); - expect(window.showInformationMessage).toHaveBeenCalledWith( - "Unable to retrieve deployment record: custom test error", - ); + test("Other than 404", async () => { + // NOT 404 errors are shown + const axiosErr = new AxiosError(); + axiosErr.response = { + data: "custom test error", + status: 401, + statusText: "401", + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + mockClient.contentRecords.get.mockRejectedValue(axiosErr); + + const currentSelection = + await publisherState.getSelectedContentRecord(); + expect(mockClient.contentRecords.get).toHaveBeenCalledTimes(1); + expect(mockClient.contentRecords.get).toHaveBeenCalledWith( + initialState.deploymentName, + initialState.projectDir, + ); + + // This error is propagated up now + expect(currentSelection).toEqual(undefined); + expect(publisherState.contentRecords).toEqual([]); + expect(window.showInformationMessage).toHaveBeenCalledWith( + "Unable to retrieve deployment record: custom test error", + ); + }); }); }); @@ -240,7 +252,8 @@ describe("PublisherState", () => { }); publisherState.contentRecords.push(contentRecord); - // setup fake config API response, note config name and project dir must be the same between content record and config + // setup fake config API response, + // config name and project dir must be the same between content record and config const config = configurationFactory.build({ configurationName: contentRecord.configurationName, projectDir: contentRecord.projectDir, @@ -297,68 +310,78 @@ describe("PublisherState", () => { expect(publisherState.configurations).toEqual([config, secondConfig]); }); - test("error responses from API", async () => { - const contentRecordState: DeploymentSelectorState = - selectionStateFactory.build(); - - const { mockContext } = mkExtensionContextStateMock({}); - const publisherState = new PublisherState(mockContext); + describe("error responses from API", () => { + let publisherState: PublisherState; + let contentRecordState: DeploymentSelectorState; + let contentRecord: PreContentRecord; - // setup existing content record in cache - const contentRecord = preContentRecordFactory.build({ - deploymentPath: contentRecordState.deploymentPath, - }); - publisherState.contentRecords.push(contentRecord); + beforeEach(() => { + contentRecordState = selectionStateFactory.build(); - // setup fake 404 error from api client - const axiosErr = new AxiosError(); - axiosErr.response = { - data: "", - status: 404, - statusText: "404", - headers: {}, - config: { headers: new AxiosHeaders() }, - }; - mockClient.configurations.get.mockRejectedValue(axiosErr); - - // set an initial state so it tries to pull from API - await publisherState.updateSelection(contentRecordState); + const { mockContext } = mkExtensionContextStateMock({}); + publisherState = new PublisherState(mockContext); - let currentConfig = await publisherState.getSelectedConfiguration(); - expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); - expect(mockClient.configurations.get).toHaveBeenCalledWith( - contentRecord.configurationName, - contentRecord.projectDir, - ); + // setup existing content record in cache + contentRecord = preContentRecordFactory.build({ + deploymentPath: contentRecordState.deploymentPath, + }); + publisherState.contentRecords.push(contentRecord); - // 404 errors are just ignored - expect(currentConfig).toEqual(undefined); - expect(publisherState.configurations).toEqual([]); - expect(window.showInformationMessage).not.toHaveBeenCalled(); - - // NOT 404 errors are shown - axiosErr.response = { - data: "custom test error", - status: 401, - statusText: "401", - headers: {}, - config: { headers: new AxiosHeaders() }, - }; - mockClient.contentRecords.get.mockRejectedValue(axiosErr); + // set an initial state so it tries to pull from API + return publisherState.updateSelection(contentRecordState); + }); - currentConfig = await publisherState.getSelectedConfiguration(); - expect(mockClient.configurations.get).toHaveBeenCalledTimes(2); - expect(mockClient.configurations.get).toHaveBeenCalledWith( - contentRecord.configurationName, - contentRecord.projectDir, - ); + test("404", async () => { + // setup fake 404 error from api client + const axiosErr = new AxiosError(); + axiosErr.response = { + data: "", + status: 404, + statusText: "404", + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + mockClient.configurations.get.mockRejectedValue(axiosErr); + + const currentConfig = await publisherState.getSelectedConfiguration(); + expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); + expect(mockClient.configurations.get).toHaveBeenCalledWith( + contentRecord.configurationName, + contentRecord.projectDir, + ); + + // 404 errors are just ignored + expect(currentConfig).toEqual(undefined); + expect(publisherState.configurations).toEqual([]); + expect(window.showInformationMessage).not.toHaveBeenCalled(); + }); - // This error is propagated up now - expect(currentConfig).toEqual(undefined); - expect(publisherState.configurations).toEqual([]); - expect(window.showInformationMessage).toHaveBeenCalledWith( - "Unable to retrieve deployment configuration: custom test error", - ); + test("Other than 404", async () => { + // NOT 404 errors are shown + const axiosErr = new AxiosError(); + axiosErr.response = { + data: "custom test error", + status: 401, + statusText: "401", + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + mockClient.configurations.get.mockRejectedValue(axiosErr); + + const currentConfig = await publisherState.getSelectedConfiguration(); + expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); + expect(mockClient.configurations.get).toHaveBeenCalledWith( + contentRecord.configurationName, + contentRecord.projectDir, + ); + + // This error is propagated up now + expect(currentConfig).toEqual(undefined); + expect(publisherState.configurations).toEqual([]); + expect(window.showInformationMessage).toHaveBeenCalledWith( + "Unable to retrieve deployment configuration: custom test error", + ); + }); }); });