diff --git a/TS_MIGRATION_PLAN.md b/TS_MIGRATION_PLAN.md new file mode 100644 index 000000000..e7d3fd285 --- /dev/null +++ b/TS_MIGRATION_PLAN.md @@ -0,0 +1,62 @@ +# TS_MIGRATION_PLAN.md + +This document describes a plan to migrate the Posit Publisher extension from +using its current Go REST API `/configurations/${encodedName}` backend to an +in-process TypeScript core package for loading a configuration. + +## Current State + +The `get` method in extensions/vscode/src/api/resources/Configurations.ts +returns a `Promise` wrapping an `AxiosResponse` wrapping the domain types +`Configuration | ConfigurationErrror` which are defined in +extensions/vscode/src/api/types/configurations.ts. + +The callsite in extensions/vscode/src/state.ts uses the `data` field of the +response. + +Errors are handled by a `catch` block that checks for Axios errors and handle +them. + +## Desired End State + +The callsite in extensions/vscode/src/state.ts calls a pure-TypeScript function +with no dependencies on vscode or axios. The `core` module defines an interface, +`ConfigurationStore`, with a `get` function that matches the api resource +function, but without the `AxiosResponse` wrapper. The `adapters` module defines +`FSConfigurationStore`, an implementation of the interface that uses the +`smol-toml` library to load and parse configurations from the file system. The +callsite in state.ts handles potential errors from the TypeScript function. + +## Package Structure + +This is a suggested package structure. + +Key details: + +- the existing `api` types are maintained in-place to minimize changes during + the migration +- `core` defines pure domain types, interfaces, and functions, with no + dependencies +- `adapters` defines an implementation with dependencies on external packages + and the file system + +``` +extensions/vscode/ +├── adapters/ +│ └── fs-configuration-store.ts # ConfigurationStore → TOML files +├── core/ +│ ├── ports.ts # ConfigurationStore interface +│ ├── errors.ts # Domain errors and error helpers +│ └── [other file names].ts # Pure domain functions (ported from Go) +├── src/ +│ ├── state.ts # Existing callsite uses new ConfigurationStore impl +``` + +## Approach + +Implement the TypeScript replacement as outlined, but stop and ask questions if +anything doesn't make sense or requires deviating from the plan. Add tests for +new code. Do not modify any go code. Make sure the extension continues to build +and tests pass using the recipes in `extensions/vscode/justfile`. If you find +yourself having to make a lot of changes to a lot of files, stop and ask for +guidance. This should be a minimally invasive refactor. diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json index fb31f43dc..ca2e7f1c4 100644 --- a/extensions/vscode/package-lock.json +++ b/extensions/vscode/package-lock.json @@ -10,6 +10,8 @@ "license": "MIT", "dependencies": { "@vscode/codicons": "^0.0.44", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "async-mutex": "^0.5.0", "axios": "^1.13.5", "debounce": "^3.0.0", @@ -18,6 +20,7 @@ "filenamify": "^7.0.1", "get-port": "^7.1.0", "retry": "^0.13.1", + "smol-toml": "^1.6.0", "tar-stream": "^3.1.7", "vscode-uri": "^3.0.8" }, @@ -1726,22 +1729,38 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -2758,6 +2777,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2794,6 +2830,13 @@ "node": ">=10.13.0" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/minimatch": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", @@ -3111,7 +3154,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -3134,6 +3176,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3874,10 +3932,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -4938,6 +4995,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -5128,6 +5194,18 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 17163ba30..2e32a4828 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -680,6 +680,8 @@ }, "dependencies": { "@vscode/codicons": "^0.0.44", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "async-mutex": "^0.5.0", "axios": "^1.13.5", "debounce": "^3.0.0", @@ -688,6 +690,7 @@ "filenamify": "^7.0.1", "get-port": "^7.1.0", "retry": "^0.13.1", + "smol-toml": "^1.6.0", "tar-stream": "^3.1.7", "vscode-uri": "^3.0.8" } diff --git a/extensions/vscode/src/adapters/fs-configuration-store.test.ts b/extensions/vscode/src/adapters/fs-configuration-store.test.ts new file mode 100644 index 000000000..052eb5ed6 --- /dev/null +++ b/extensions/vscode/src/adapters/fs-configuration-store.test.ts @@ -0,0 +1,174 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { isConfigurationError } from "src/api/types/configurations"; +import { FSConfigurationStore } from "./fs-configuration-store"; + +describe("FSConfigurationStore", () => { + let tmpDir: string; + let store: FSConfigurationStore; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-test-")); + await fs.mkdir(path.join(tmpDir, ".posit", "publish"), { recursive: true }); + store = new FSConfigurationStore(tmpDir); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + async function writeConfig(name: string, content: string) { + await fs.writeFile( + path.join(tmpDir, ".posit", "publish", `${name}.toml`), + content, + ); + } + + test("reads a valid configuration", async () => { + await writeConfig( + "myconfig", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" +validate = true + +[python] +version = "3.11.3" +package_file = "requirements.txt" +package_manager = "pip" +`, + ); + + const result = await store.get("myconfig", "."); + expect(isConfigurationError(result)).toBe(false); + + if (!isConfigurationError(result)) { + expect(result.configurationName).toBe("myconfig"); + expect(result.projectDir).toBe("."); + expect(result.configuration.type).toBe("python-dash"); + expect(result.configuration.entrypoint).toBe("app.py"); + expect(result.configuration.python?.packageFile).toBe("requirements.txt"); + expect(result.configuration.python?.packageManager).toBe("pip"); + expect(result.configuration.python?.version).toBe("3.11.3"); + } + }); + + test("reads a config with environment variables (keys preserved)", async () => { + await writeConfig( + "envconfig", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" +validate = true + +[python] +version = "3.11" + +[environment] +MY_API_KEY = "https://example.com" +DATABASE_URL = "postgres://localhost" +`, + ); + + const result = await store.get("envconfig", "."); + expect(isConfigurationError(result)).toBe(false); + + if (!isConfigurationError(result)) { + expect(result.configuration.environment).toEqual({ + MY_API_KEY: "https://example.com", + DATABASE_URL: "postgres://localhost", + }); + } + }); + + test("returns ConfigurationError for invalid TOML syntax", async () => { + await writeConfig("badtoml", "this is not [valid toml"); + + const result = await store.get("badtoml", "."); + expect(isConfigurationError(result)).toBe(true); + + if (isConfigurationError(result)) { + expect(result.error.code).toBe("invalidTOML"); + expect(result.configurationName).toBe("badtoml"); + } + }); + + test("returns ConfigurationError for schema validation failure", async () => { + // Missing required fields: $schema, type, entrypoint + await writeConfig("invalid", `title = "Missing required fields"`); + + const result = await store.get("invalid", "."); + expect(isConfigurationError(result)).toBe(true); + + if (isConfigurationError(result)) { + expect(result.error.code).toBe("tomlValidationError"); + expect(result.configurationName).toBe("invalid"); + } + }); + + test("throws for missing file (ENOENT)", async () => { + await expect(store.get("nonexistent", ".")).rejects.toThrow(/ENOENT/); + }); + + test("reads a config with connect runtime settings", async () => { + await writeConfig( + "connectconfig", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-fastapi" +entrypoint = "app:api" +validate = true + +[python] +version = "3.11" + +[connect.runtime] +connection_timeout = 5 +read_timeout = 30 +max_processes = 3 +min_processes = 1 +`, + ); + + const result = await store.get("connectconfig", "."); + expect(isConfigurationError(result)).toBe(false); + + if (!isConfigurationError(result)) { + const runtime = result.configuration.connect?.runtime; + expect(runtime?.connectionTimeout).toBe(5); + expect(runtime?.readTimeout).toBe(30); + expect(runtime?.maxProcesses).toBe(3); + expect(runtime?.minProcesses).toBe(1); + } + }); + + test("sets correct location metadata", async () => { + await writeConfig( + "loctest", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +validate = true +`, + ); + + const result = await store.get("loctest", "."); + expect(result.configurationName).toBe("loctest"); + expect(result.configurationPath).toBe( + path.join(tmpDir, ".posit", "publish", "loctest.toml"), + ); + expect(result.configurationRelPath).toBe( + path.join(".posit", "publish", "loctest.toml"), + ); + expect(result.projectDir).toBe("."); + }); +}); diff --git a/extensions/vscode/src/adapters/fs-configuration-store.ts b/extensions/vscode/src/adapters/fs-configuration-store.ts new file mode 100644 index 000000000..40ef50173 --- /dev/null +++ b/extensions/vscode/src/adapters/fs-configuration-store.ts @@ -0,0 +1,109 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { parse as parseTOML } from "smol-toml"; +import Ajv from "ajv/dist/2020"; +import addFormats from "ajv-formats"; + +import { + Configuration, + ConfigurationDetails, + ConfigurationError, +} from "src/api/types/configurations"; +import { ConfigurationStore } from "src/core/ports"; +import { + createInvalidTOMLError, + createSchemaValidationError, + createConfigurationError, +} from "src/core/errors"; +import { convertKeysToCamelCase } from "src/core/conversion"; +import schema from "src/core/schemas/posit-publishing-schema-v3.json"; + +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); +const validate = ajv.compile(schema); + +export class FSConfigurationStore implements ConfigurationStore { + private readonly workspaceRoot: string; + + constructor(workspaceRoot: string) { + this.workspaceRoot = workspaceRoot; + } + + async get( + configName: string, + projectDir: string, + ): Promise { + const absoluteProjectDir = path.resolve(this.workspaceRoot, projectDir); + const configPath = path.join( + absoluteProjectDir, + ".posit", + "publish", + `${configName}.toml`, + ); + const relPath = path.join(".posit", "publish", `${configName}.toml`); + + const location = { + configurationName: configName, + configurationPath: configPath, + configurationRelPath: relPath, + projectDir, + }; + + // Read file — ENOENT (file-not-found) throws to caller + const content = await fs.readFile(configPath, "utf-8"); + + // Parse TOML + let rawData: Record; + try { + rawData = parseTOML(content) as Record; + } catch (err: unknown) { + const problem = + err instanceof Error ? err.message : "Failed to parse TOML"; + // smol-toml errors have `line` and `column` properties + const line = hasNumericProp(err, "line") ? err.line : 1; + const column = hasNumericProp(err, "column") ? err.column : 1; + return createConfigurationError( + createInvalidTOMLError(configPath, problem, line, column), + location, + ); + } + + // Validate against JSON schema (snake_case data) + const valid = validate(rawData); + if (!valid && validate.errors?.length) { + const firstError = validate.errors[0]!; + const message = firstError.instancePath + ? `${firstError.instancePath}: ${firstError.message}` + : (firstError.message ?? "Schema validation failed"); + return createConfigurationError( + createSchemaValidationError(configPath, message), + location, + ); + } + + // Convert snake_case keys to camelCase + const camelCaseData = convertKeysToCamelCase( + rawData, + ) as ConfigurationDetails; + + return { + ...location, + configuration: camelCaseData, + }; + } +} + +function hasNumericProp( + obj: unknown, + key: K, +): obj is Record { + return ( + obj !== null && + typeof obj === "object" && + key in obj && + typeof (obj as Record)[key] === "number" + ); +} diff --git a/extensions/vscode/src/core/conversion.test.ts b/extensions/vscode/src/core/conversion.test.ts new file mode 100644 index 000000000..51ac48300 --- /dev/null +++ b/extensions/vscode/src/core/conversion.test.ts @@ -0,0 +1,124 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { describe, expect, test } from "vitest"; +import { convertKeysToCamelCase } from "./conversion"; + +describe("convertKeysToCamelCase", () => { + test("converts simple snake_case keys", () => { + const input = { package_file: "requirements.txt", package_manager: "pip" }; + expect(convertKeysToCamelCase(input)).toEqual({ + packageFile: "requirements.txt", + packageManager: "pip", + }); + }); + + test("leaves already-camelCase keys unchanged", () => { + const input = { version: "3.11", entrypoint: "app.py" }; + expect(convertKeysToCamelCase(input)).toEqual({ + version: "3.11", + entrypoint: "app.py", + }); + }); + + test("converts nested objects", () => { + const input = { + python: { + package_file: "requirements.txt", + package_manager: "pip", + }, + connect: { + runtime: { + connection_timeout: 5, + max_conns_per_process: 50, + }, + }, + }; + expect(convertKeysToCamelCase(input)).toEqual({ + python: { + packageFile: "requirements.txt", + packageManager: "pip", + }, + connect: { + runtime: { + connectionTimeout: 5, + maxConnsPerProcess: 50, + }, + }, + }); + }); + + test("converts arrays of objects", () => { + const input = { + integration_requests: [ + { auth_type: "oauth", display_name: "Test" }, + { auth_type: "api_key", display_name: "Other" }, + ], + }; + expect(convertKeysToCamelCase(input)).toEqual({ + integrationRequests: [ + { authType: "oauth", displayName: "Test" }, + { authType: "api_key", displayName: "Other" }, + ], + }); + }); + + test("preserves environment keys without conversion", () => { + const input = { + environment: { + MY_API_KEY: "value1", + DATABASE_URL: "postgres://localhost", + simple: "value2", + }, + }; + expect(convertKeysToCamelCase(input)).toEqual({ + environment: { + MY_API_KEY: "value1", + DATABASE_URL: "postgres://localhost", + simple: "value2", + }, + }); + }); + + test("handles primitive values", () => { + expect(convertKeysToCamelCase("hello")).toBe("hello"); + expect(convertKeysToCamelCase(42)).toBe(42); + expect(convertKeysToCamelCase(true)).toBe(true); + expect(convertKeysToCamelCase(null)).toBe(null); + }); + + test("handles arrays of primitives", () => { + const input = { files: ["app.py", "model.csv"] }; + expect(convertKeysToCamelCase(input)).toEqual({ + files: ["app.py", "model.csv"], + }); + }); + + test("converts product_type to productType", () => { + const input = { product_type: "connect", $schema: "https://example.com" }; + expect(convertKeysToCamelCase(input)).toEqual({ + productType: "connect", + $schema: "https://example.com", + }); + }); + + test("converts connect_cloud nested structure", () => { + const input = { + connect_cloud: { + vanity_name: "my-app", + access_control: { + public_access: true, + organization_access: "viewer", + }, + }, + }; + expect(convertKeysToCamelCase(input)).toEqual({ + connectCloud: { + vanityName: "my-app", + accessControl: { + publicAccess: true, + organizationAccess: "viewer", + }, + }, + }); + }); +}); diff --git a/extensions/vscode/src/core/conversion.ts b/extensions/vscode/src/core/conversion.ts new file mode 100644 index 000000000..571d48521 --- /dev/null +++ b/extensions/vscode/src/core/conversion.ts @@ -0,0 +1,30 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +function snakeToCamel(key: string): string { + return key.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase()); +} + +/** + * Recursively converts all object keys from snake_case to camelCase. + * Skips conversion inside `environment` maps (user-defined env var names). + */ +export function convertKeysToCamelCase( + obj: unknown, + insideEnvironment = false, +): unknown { + if (Array.isArray(obj)) { + return obj.map((item) => convertKeysToCamelCase(item, false)); + } + + if (obj !== null && typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const newKey = insideEnvironment ? key : snakeToCamel(key); + const isEnvironmentKey = key === "environment"; + result[newKey] = convertKeysToCamelCase(value, isEnvironmentKey); + } + return result; + } + + return obj; +} diff --git a/extensions/vscode/src/core/errors.test.ts b/extensions/vscode/src/core/errors.test.ts new file mode 100644 index 000000000..33bf0e704 --- /dev/null +++ b/extensions/vscode/src/core/errors.test.ts @@ -0,0 +1,65 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { describe, expect, test } from "vitest"; +import { + createInvalidTOMLError, + createSchemaValidationError, + createConfigurationError, +} from "./errors"; + +describe("createInvalidTOMLError", () => { + test("creates error with invalidTOML code", () => { + const err = createInvalidTOMLError( + "config.toml", + "unexpected token", + 5, + 10, + ); + expect(err.code).toBe("invalidTOML"); + expect(err.msg).toBe("unexpected token"); + expect(err.operation).toBe("parse"); + expect(err.data.problem).toBe("unexpected token"); + expect(err.data.file).toBe("config.toml"); + expect(err.data.line).toBe(5); + expect(err.data.column).toBe(10); + }); +}); + +describe("createSchemaValidationError", () => { + test("creates error with tomlValidationError code", () => { + const err = createSchemaValidationError( + "config.toml", + "missing required field: type", + ); + expect(err.code).toBe("tomlValidationError"); + expect(err.msg).toBe("missing required field: type"); + expect(err.operation).toBe("validate"); + }); +}); + +describe("createConfigurationError", () => { + test("combines error with location metadata", () => { + const agentError = createInvalidTOMLError( + "config.toml", + "bad syntax", + 1, + 1, + ); + const location = { + configurationName: "myconfig", + configurationPath: "/project/.posit/publish/myconfig.toml", + configurationRelPath: ".posit/publish/myconfig.toml", + projectDir: "/project", + }; + + const result = createConfigurationError(agentError, location); + + expect(result.error).toBe(agentError); + expect(result.configurationName).toBe("myconfig"); + expect(result.configurationPath).toBe( + "/project/.posit/publish/myconfig.toml", + ); + expect(result.configurationRelPath).toBe(".posit/publish/myconfig.toml"); + expect(result.projectDir).toBe("/project"); + }); +}); diff --git a/extensions/vscode/src/core/errors.ts b/extensions/vscode/src/core/errors.ts new file mode 100644 index 000000000..5de56e71a --- /dev/null +++ b/extensions/vscode/src/core/errors.ts @@ -0,0 +1,51 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { AgentError, AgentErrorInvalidTOML } from "src/api/types/error"; +import { + ConfigurationError, + ConfigurationLocation, +} from "src/api/types/configurations"; + +export function createInvalidTOMLError( + file: string, + problem: string, + line: number, + column: number, +): AgentErrorInvalidTOML { + return { + code: "invalidTOML", + msg: problem, + operation: "parse", + data: { + problem, + file, + line, + column, + }, + }; +} + +export function createSchemaValidationError( + file: string, + message: string, +): AgentError { + return { + code: "tomlValidationError", + msg: message, + operation: "validate", + data: { + problem: message, + file, + }, + }; +} + +export function createConfigurationError( + error: AgentError, + location: ConfigurationLocation, +): ConfigurationError { + return { + ...location, + error, + }; +} diff --git a/extensions/vscode/src/core/ports.ts b/extensions/vscode/src/core/ports.ts new file mode 100644 index 000000000..9db6a4409 --- /dev/null +++ b/extensions/vscode/src/core/ports.ts @@ -0,0 +1,13 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { + Configuration, + ConfigurationError, +} from "src/api/types/configurations"; + +export interface ConfigurationStore { + get( + configName: string, + projectDir: string, + ): Promise; +} diff --git a/extensions/vscode/src/core/schemas/posit-publishing-schema-v3.json b/extensions/vscode/src/core/schemas/posit-publishing-schema-v3.json new file mode 100644 index 000000000..ce7457aa8 --- /dev/null +++ b/extensions/vscode/src/core/schemas/posit-publishing-schema-v3.json @@ -0,0 +1,598 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "type": "object", + "description": "Posit Publishing Configuration", + "$defs": { + "config_connect": { + "description": "Extensions to the base schema for Posit Connect deployments.", + "properties": { + "has_parameters": { + "type": "boolean", + "description": "True if this is a report that accepts parameters.", + "default": false + }, + "python": { + "type": "object", + "additionalProperties": false, + "description": "Python language and dependencies.", + "properties": { + "version": {}, + "package_file": { + "type": "string", + "description": "File containing package dependencies. The file must exist and be listed under 'files'. The default is 'requirements.txt'.", + "default": "requirements.txt", + "examples": ["requirements.txt"] + }, + "package_manager": { + "type": "string", + "default": "pip", + "description": "Package manager that will install the dependencies. If package-manager is none, dependencies are assumed to be pre-installed on the server. The default is 'pip'.", + "examples": ["pip", "conda", "pipenv", "poetry", "none"] + }, + "requires_python": { + "type": "string", + "default": "", + "description": "Python interpreter version, in PEP 440 format, required to run the content. If not specified it will be detected from the one in use.", + "examples": [">3.8", "<3.9", "==3.5"] + } + } + }, + "r": { + "type": "object", + "additionalProperties": false, + "description": "R language and dependencies.", + "properties": { + "version": {}, + "package_file": { + "type": "string", + "default": "renv.lock", + "description": "File containing package dependencies. The file must exist and be listed under 'files'.", + "examples": ["renv.lock"] + }, + "package_manager": { + "type": "string", + "default": "renv", + "description": "Package manager that will install the dependencies. If package-manager is none, dependencies will be assumed to be pre-installed on the server.", + "examples": ["renv", "none"] + }, + "requires_r": { + "type": "string", + "default": "", + "description": "R interpreter version, required to run the content. If not specified it will be detected from the one in use.", + "examples": [">3.8", "<4.3", ">=3.5.0"] + }, + "packages_from_library": { + "type": "boolean", + "default": false, + "description": "When true, generate manifest packages from the local renv library (legacy behavior). When false (default), read packages from renv.lock." + } + } + }, + "jupyter": { + "type": "object", + "additionalProperties": false, + "description": "Additional rendering options for Jupyter Notebooks.", + "properties": { + "hide_all_input": { + "type": "boolean", + "description": "Hide all input cells when rendering output." + }, + "hide_tagged_input": { + "type": "boolean", + "description": "Hide input code cells with the 'hide_input' tag when rendering output." + } + } + }, + "quarto": { + "type": "object", + "additionalProperties": false, + "description": "Quarto version required to run the content.", + "required": ["version"], + "properties": { + "version": { + "type": "string", + "description": "Quarto version. The server must have a similar Quarto version in order to run the content.", + "examples": ["1.4"] + }, + "engines": { + "type": "array", + "description": "List of Quarto engines required for this content.", + "items": { + "type": "string", + "enum": ["knitr", "jupyter", "markdown"], + "examples": ["knitr", "jupyter", "markdown"] + } + } + } + }, + "connect": { + "type": "object", + "additionalProperties": false, + "description": "Setting specific to Posit Connect deployments.", + "properties": { + "runtime": {}, + "kubernetes": { + "type": "object", + "additionalProperties": false, + "description": "Settings used with Posit Connect's off-host execution feature, where content is run in Kubernetes.", + "properties": { + "amd_gpu_limit": { + "type": "integer", + "minimum": 0, + "description": "The number of AMD GPUs that will be allocated by Kubernetes to run this content.", + "examples": [0] + }, + "cpu_limit": { + "type": "number", + "minimum": 0, + "description": "The maximum amount of compute power this content will be allowed to consume when executing or rendering, expressed in CPU Units, where 1.0 unit is equivalent to 1 physical or virtual core. Fractional values are allowed. If the process tries to use more CPU than allowed, it will be throttled.", + "examples": [1] + }, + "cpu_request": { + "type": "number", + "minimum": 0, + "description": "The minimum amount of compute power this content needs when executing virtual core. Fractional values are allowed.", + "examples": [0.5] + }, + "default_image_name": { + "type": "string", + "pattern": "^[^\t\n\b\f\r ]*$", + "description": "Name of the target container image.", + "examples": ["posit/connect-runtime-python3.11-r4.3"] + }, + "memory_limit": { + "type": "integer", + "minimum": 0, + "description": "The maximum amount of RAM this content will be allowed to consume when executing or rendering, expressed in bytes. If the process tries to use more memory than allowed, it will be terminated", + "examples": ["100000000"] + }, + "memory_request": { + "type": "integer", + "minimum": 0, + "description": "The minimum amount of RAM this content needs when executing or rendering, expressed in bytes.", + "examples": ["20000000"] + }, + "nvidia_gpu_limit": { + "type": "integer", + "minimum": 0, + "description": "The number of NVIDIA GPUs that will be allocated by Kubernetes to run this content.", + "examples": [0] + }, + "service_account_name": { + "type": "string", + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$", + "description": "The name of the Kubernetes service account that is used to run this content. It must adhere to Kubernetes service account naming rules. You must be an administrator to set this value.", + "examples": ["posit-connect-content"] + }, + "default_r_environment_management": { + "type": "boolean", + "description": "Enables or disables R environment management. When false, Posit Connect will not install R packages and instead assume that all required packages are present in the container image.", + "examples": [true] + }, + "default_py_environment_management": { + "type": "boolean", + "description": "Enables or disables Python environment management. When false, Posit Connect will not install Python packages and instead assume that all required packages are present in the container image.", + "examples": [true] + } + } + }, + "access": { + "type": "object", + "additionalProperties": false, + "properties": { + "run_as": { + "type": "string", + "description": "The system username under which the content should be run. Must be an existing user in the allowed group. You must be an administrator to set this value.", + "examples": ["rstudio-connect"] + }, + "run_as_current_user": { + "type": "boolean", + "default": false, + "description": "For application content types, run a separate process under the user account of each visiting user under that user's server account. Requires PAM authentication on the Posit Connect server. You must be an administrator to set this value." + } + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": ["quarto-shiny", "quarto", "quarto-static"] + } + }, + "required": ["type"] + }, + "then": { + "required": ["quarto"] + } + } + ] + }, + "config_connect_cloud": { + "description": "Extensions to the base schema for Posit Connect Cloud deployments.", + "properties": { + "product_type": { + "type": "string", + "description": "Product type indicating this is a Posit Connect Cloud configuration.", + "enum": ["connect_cloud"] + }, + "python": { + "type": "object", + "additionalProperties": false, + "description": "Python language and dependencies.", + "properties": { + "version": {} + } + }, + "r": { + "type": "object", + "additionalProperties": false, + "description": "R language and dependencies.", + "properties": { + "version": {} + } + }, + "connect_cloud": { + "type": "object", + "additionalProperties": false, + "description": "Setting specific to Posit Connect Cloud deployments.", + "properties": { + "python": { + "type": "object", + "additionalProperties": false, + "description": "Python language and dependencies.", + "properties": { + "version": {} + } + }, + "r": { + "type": "object", + "additionalProperties": false, + "description": "R language and dependencies.", + "properties": { + "version": {} + } + }, + "vanity_name": { + "type": "string", + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])$", + "description": "The custom component of the vanity URL. If your account name is 'my-account' and this value is 'vanity', the vanity URL will be https://my-account-vanity.share.connect.posit.cloud.", + "examples": ["sales-report"] + }, + "access_control": { + "type": "object", + "additionalProperties": false, + "properties": { + "public_access": { + "type": "boolean", + "description": "Whether or not the content is publicly accessible." + }, + "organization_access": { + "type": "string", + "enum": ["disabled", "viewer", "editor"], + "default": "disabled", + "description": "The default level of access for account members within an organizational account." + } + } + } + } + }, + "connect": { + "additionalProperties": false, + "properties": { + "runtime": {} + } + } + } + } + }, + "unevaluatedProperties": false, + "properties": { + "product_type": { + "type": "string", + "description": "The server type that this content will be deployed to.", + "enum": ["connect", "snowflake", "connect_cloud"], + "default": "connect" + }, + "comments": { + "type": "array", + "items": { + "type": ["string"] + }, + "description": "Comments are allowed at the top of the configuration file, with a leading '#' character." + }, + "$schema": { + "type": "string", + "format": "url", + "description": "URL of the json-schema definition for this file. Must be 'https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json'.", + "examples": [ + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" + ] + }, + "type": { + "type": "string", + "description": "Indicates the type of content being deployed. Valid values are: html, jupyter-notebook, jupyter-voila, python-bokeh, python-dash, python-fastapi, python-flask, python-gradio, python-panel, python-shiny, python-streamlit, quarto-shiny, quarto-static, r-plumber, r-shiny, rmd-shiny, rmd", + "enum": [ + "html", + "jupyter-notebook", + "jupyter-voila", + "python-bokeh", + "python-dash", + "python-fastapi", + "python-flask", + "python-gradio", + "python-panel", + "python-shiny", + "python-streamlit", + "quarto-shiny", + "quarto", + "quarto-static", + "r-plumber", + "r-shiny", + "rmd-shiny", + "rmd", + "unknown" + ], + "examples": ["quarto-static"] + }, + "entrypoint": { + "type": "string", + "description": "Name of the primary file containing the content. For Python flask, dash, fastapi, and python-shiny projects, this specifies the object within the file in module:object format. See the documentation at https://docs.posit.co/connect/user/publishing-cli-apps/#publishing-rsconnect-python-entrypoint.", + "examples": ["app.py", "report.qmd"] + }, + "source": { + "type": "string", + "description": "Name of the primary file that acts as the source for content that can be rendered on demand. Only useful when deploying static content which has been rendered from Quarto, Rmarkdown or Jupyter Notebooks", + "examples": ["report.qmd", "_quarto.yml"] + }, + "title": { + "type": "string", + "pattern": "^[^\t\n\f\r]{3,1000}$|", + "description": "Title for this content. If specified, it must be a single line containing between 3 and 1000 characters.", + "examples": ["Quarterly Sales Report"] + }, + "description": { + "type": "string", + "pattern": "^[^\t\f\r]*$", + "description": "Description for this content. It may span multiple lines and be up to 4000 characters.", + "examples": ["This is the quarterly sales report, broken down by region."] + }, + "validate": { + "type": "boolean", + "description": "Access the content after deploying, to validate that it is live. Defaults to true.", + "default": true + }, + "files": { + "type": "array", + "items": { + "type": ["string"] + }, + "description": "Project-relative paths of the files to be included in the deployment. Wildcards are accepted, using .gitignore syntax.", + "examples": ["app.py", "model/*.csv", "!model/excludeme.csv"] + }, + "python": { + "$comment": "Note: this object is extended by the other config definitions. Extensions should use additionalProperties: false to prevent unexpected properties.", + "type": "object", + "description": "Python language and dependencies.", + "properties": { + "version": { + "type": "string", + "description": "Python version. The server must have a matching Python major/minor version in order to run the content.", + "examples": ["3.11.3", "3.11"] + } + } + }, + "r": { + "$comment": "Note: this object is extended by the other config definitions. Extensions should use additionalProperties: false to prevent unexpected properties.", + "type": "object", + "description": "R language and dependencies.", + "properties": { + "version": { + "type": "string", + "description": "R version. The server will use the nearest R version to run the content.", + "examples": ["4.3.1"] + } + } + }, + "environment": { + "type": "object", + "additionalProperties": { + "type": ["string"] + }, + "description": "Environment variable/value map. All values must be strings. Secrets such as API keys or tokens should not be stored here.", + "examples": [ + { + "API_URL": "https://example.com/api" + } + ] + }, + "secrets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of secrets required by the application. Injected as environment variables.", + "examples": ["API_KEY", "DATABASE_PASSWORD"] + }, + "integration_requests": { + "type": "array", + "description": "Integration requests associated with this configuration", + "items": { + "type": "object", + "properties": { + "guid": { + "type": "string", + "description": "Unique identifier for the integration request" + }, + "name": { + "type": "string", + "description": "Name of the integration request" + }, + "description": { + "type": "string", + "description": "Description of the integration request" + }, + "authType": { + "type": "string", + "description": "Authentication type for the integration request" + }, + "type": { + "type": "string", + "description": "Integration type" + }, + "config": { + "type": "object", + "additionalProperties": true, + "description": "Configuration for the integration request" + } + } + } + }, + "connect": { + "type": "object", + "description": "Settings for Posit Connect applications.", + "properties": { + "runtime": { + "type": "object", + "additionalProperties": false, + "description": "Runtime settings for application content types.", + "properties": { + "connection_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 2592000, + "description": "Maximum number of seconds allowed without data sent or received across a client connection. A value of `0` means connections will never time-out (not recommended).", + "examples": [5] + }, + "read_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 2592000, + "description": "Maximum number of seconds allowed without data received from a client connection. A value of `0` means a lack of client (browser) interaction never causes the connection to close.", + "examples": [30] + }, + "init_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 2592000, + "description": "The maximum number of seconds allowed for an interactive application to start. Posit Connect must be able to connect to a newly launched application before this threshold has elapsed.", + "examples": [60] + }, + "idle_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 2592000, + "description": "The maximum number of seconds a worker process for an interactive application to remain alive after it goes idle (no active connections).", + "examples": [120] + }, + "max_processes": { + "type": "integer", + "minimum": 1, + "description": "Specifies the total number of concurrent processes allowed for a single interactive application.", + "examples": [5] + }, + "min_processes": { + "type": "integer", + "minimum": 0, + "description": "Specifies the minimum number of concurrent processes allowed for a single interactive application.", + "examples": [1] + }, + "max_conns_per_process": { + "type": "integer", + "minimum": 1, + "description": "Specifies the maximum number of client connections allowed to an individual process. Incoming connections which will exceed this limit are routed to a new process or rejected.", + "examples": [50] + }, + "load_factor": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Controls how aggressively new processes are spawned. The valid range is between 0.0 and 1.0.", + "examples": [0.5] + } + } + } + } + } + }, + "required": ["$schema", "type", "entrypoint"], + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "jupyter-notebook", + "jupyter-voila", + "python-bokeh", + "python-dash", + "python-fastapi", + "python-flask", + "python-gradio", + "python-panel", + "python-shiny", + "python-streamlit" + ] + } + }, + "required": ["type"] + }, + "then": { + "required": ["python"] + } + }, + { + "if": { + "properties": { + "type": { + "enum": ["r-plumber", "r-shiny", "rmd-shiny", "rmd"] + } + }, + "required": ["type"] + }, + "then": { + "required": ["r"] + } + }, + { + "if": { + "anyOf": [ + { + "$comment": "Default to connect if product_type is not specified.", + "properties": { + "product_type": false + } + }, + { + "required": ["product_type"], + "properties": { + "product_type": { + "const": "connect" + } + } + } + ] + }, + "then": { + "$ref": "#/$defs/config_connect" + } + }, + { + "if": { + "required": ["product_type"], + "properties": { + "product_type": { + "const": "connect_cloud" + } + } + }, + "then": { + "$ref": "#/$defs/config_connect_cloud" + } + } + ] +} diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index 111985e2f..62d823d40 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -13,7 +13,13 @@ import { import { mkExtensionContextStateMock } from "src/test/unit-test-utils/vscode-mocks"; import { LocalState } from "./constants"; import { PublisherState } from "./state"; -import { AllContentRecordTypes, PreContentRecord } from "src/api"; +import { + AllContentRecordTypes, + Configuration, + ConfigurationError, + PreContentRecord, +} from "src/api"; +import { ConfigurationStore } from "src/core/ports"; class mockApiClient { readonly contentRecords = { @@ -75,6 +81,22 @@ vi.mock("src/utils/vscode", () => ({ getRInterpreterPath: vi.fn(), })); +vi.mock("./workspaces", () => ({ + path: vi.fn(() => "/workspace"), +})); + +class MockConfigStore implements ConfigurationStore { + get = + vi.fn< + ( + configName: string, + projectDir: string, + ) => Promise + >(); +} + +const mockConfigStore = new MockConfigStore(); + vi.mock("vscode", () => { // mock Disposable const disposableMock = vi.fn(); @@ -285,13 +307,13 @@ describe("PublisherState", () => { selectionStateFactory.build(); const { mockContext, mockWorkspace } = mkExtensionContextStateMock({}); - const publisherState = new PublisherState(mockContext); + const publisherState = new PublisherState(mockContext, mockConfigStore); // No config get due to no content record set let currentConfig = await publisherState.getSelectedConfiguration(); expect(mockWorkspace.get).toHaveBeenCalled(); expect(currentConfig).toEqual(undefined); - expect(mockClient.configurations.get).not.toHaveBeenCalled(); + expect(mockConfigStore.get).not.toHaveBeenCalled(); // setup existing content record in cache const contentRecord = preContentRecordFactory.build({ @@ -299,22 +321,20 @@ describe("PublisherState", () => { }); publisherState.contentRecords.push(contentRecord); - // setup fake config API response, + // setup fake config store 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, }); - mockClient.configurations.get.mockResolvedValue({ - data: config, - }); + mockConfigStore.get.mockResolvedValue(config); // selection has something now await publisherState.updateSelection(contentRecordState); currentConfig = await publisherState.getSelectedConfiguration(); - expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); - expect(mockClient.configurations.get).toHaveBeenCalledWith( + expect(mockConfigStore.get).toHaveBeenCalledTimes(1); + expect(mockConfigStore.get).toHaveBeenCalledWith( contentRecord.configurationName, contentRecord.projectDir, ); @@ -325,11 +345,11 @@ describe("PublisherState", () => { currentConfig = await publisherState.getSelectedConfiguration(); // Only the previous call is registered - expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); + expect(mockConfigStore.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 + // setup a second content record in cache and it's respective config store response const secondContentRecordState: DeploymentSelectorState = selectionStateFactory.build(); const secondContentRecord = preContentRecordFactory.build({ @@ -341,9 +361,7 @@ describe("PublisherState", () => { configurationName: secondContentRecord.configurationName, projectDir: secondContentRecord.projectDir, }); - mockClient.configurations.get.mockResolvedValue({ - data: secondConfig, - }); + mockConfigStore.get.mockResolvedValue(secondConfig); // selection has something different this time await publisherState.updateSelection(secondContentRecordState); @@ -351,13 +369,13 @@ describe("PublisherState", () => { // 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); + // Two config store calls were triggered, each for every different + expect(mockConfigStore.get).toHaveBeenCalledTimes(2); expect(currentConfig).toEqual(secondConfig); expect(publisherState.configurations).toEqual([config, secondConfig]); }); - describe("error responses from API", () => { + describe("error responses from config store", () => { let publisherState: PublisherState; let contentRecordState: DeploymentSelectorState; let contentRecord: PreContentRecord; @@ -366,7 +384,7 @@ describe("PublisherState", () => { contentRecordState = selectionStateFactory.build(); const { mockContext } = mkExtensionContextStateMock({}); - publisherState = new PublisherState(mockContext); + publisherState = new PublisherState(mockContext, mockConfigStore); // setup existing content record in cache contentRecord = preContentRecordFactory.build({ @@ -374,12 +392,34 @@ describe("PublisherState", () => { }); publisherState.contentRecords.push(contentRecord); - // set an initial state so it tries to pull from API + // set an initial state so it tries to pull from config store return publisherState.updateSelection(contentRecordState); }); - test("404", async () => { - // setup fake 404 error from api client + test("ENOENT (file not found)", async () => { + // setup fake ENOENT error from config store + const enoentErr = Object.assign(new Error("ENOENT: no such file"), { + code: "ENOENT", + }); + mockConfigStore.get.mockRejectedValue(enoentErr); + + const currentConfig = await publisherState.getSelectedConfiguration(); + expect(mockConfigStore.get).toHaveBeenCalledTimes(1); + + // ENOENT errors are just ignored + expect(currentConfig).toEqual(undefined); + expect(publisherState.configurations).toEqual([]); + expect(window.showInformationMessage).not.toHaveBeenCalled(); + }); + + test("404 from interpreters API", async () => { + // Config loads fine, but interpreters returns 404 + const config = configurationFactory.build({ + configurationName: contentRecord.configurationName, + projectDir: contentRecord.projectDir, + }); + mockConfigStore.get.mockResolvedValue(config); + const axiosErr = new AxiosError(); axiosErr.response = { data: "", @@ -388,39 +428,23 @@ describe("PublisherState", () => { headers: {}, config: { headers: new AxiosHeaders() }, }; - mockClient.configurations.get.mockRejectedValue(axiosErr); + mockClient.interpreters.get.mockRejectedValueOnce(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 + // 404 errors are silently ignored expect(currentConfig).toEqual(undefined); expect(publisherState.configurations).toEqual([]); expect(window.showInformationMessage).not.toHaveBeenCalled(); }); - 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); + test("Other errors are shown", async () => { + // Non-ENOENT errors are shown to the user + const err = new Error("custom test error"); + mockConfigStore.get.mockRejectedValue(err); const currentConfig = await publisherState.getSelectedConfiguration(); - expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); - expect(mockClient.configurations.get).toHaveBeenCalledWith( - contentRecord.configurationName, - contentRecord.projectDir, - ); + expect(mockConfigStore.get).toHaveBeenCalledTimes(1); // This error is propagated up now expect(currentConfig).toEqual(undefined); diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index 0de9867ab..8a3b56411 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -36,6 +36,17 @@ import { isConnectCloudProduct, } from "./utils/multiStepHelpers"; import { recordAddConnectCloudUrlParams } from "./utils/connectCloudHelpers"; +import { ConfigurationStore } from "src/core/ports"; +import { FSConfigurationStore } from "src/adapters/fs-configuration-store"; +import * as workspaces from "./workspaces"; + +function getDefaultConfigStore(): ConfigurationStore { + const root = workspaces.path(); + if (!root) { + throw new Error("No workspace folder open"); + } + return new FSConfigurationStore(root); +} function findContentRecord< T extends ContentRecord | PreContentRecord | PreContentRecordWithConfig, @@ -100,6 +111,7 @@ export interface CredentialRefreshEvent { export class PublisherState implements Disposable { private readonly context: extensionContext; + private readonly configStore: ConfigurationStore; private credentialRefresh = new EventEmitter(); contentRecords: Array< @@ -108,8 +120,9 @@ export class PublisherState implements Disposable { configurations: Array = []; credentials: Credential[] = []; - constructor(context: extensionContext) { + constructor(context: extensionContext, configStore?: ConfigurationStore) { this.context = context; + this.configStore = configStore ?? getDefaultConfigStore(); } dispose() { @@ -213,46 +226,52 @@ export class PublisherState implements Disposable { if (!contentRecord) { return undefined; } - const cfg = this.findValidConfig( + const cachedCfg = this.findValidConfig( contentRecord.configurationName, contentRecord.projectDir, ); - if (cfg) { - return cfg; + if (cachedCfg) { + return cachedCfg; } - // if not found, then retrieve it and add it to our cache. + // if not found, then retrieve it from disk and add it to our cache. try { - const api = await useApi(); - const python = await getPythonInterpreterPath(); - const r = await getRInterpreterPath(); - - const response = await api.configurations.get( + const configResult = await this.configStore.get( contentRecord.configurationName, contentRecord.projectDir, ); + + // Apply interpreter defaults from Go API + const api = await useApi(); + const python = await getPythonInterpreterPath(); + const r = await getRInterpreterPath(); const defaults = await api.interpreters.get( contentRecord.projectDir, r, python, ); - const cfg = UpdateConfigWithDefaults(response.data, defaults.data); + const cfg = UpdateConfigWithDefaults(configResult, defaults.data); // its not foolproof, but it may help if (!this.findConfig(cfg.configurationName, cfg.projectDir)) { this.configurations.push(cfg); } return cfg; } catch (error: unknown) { + if (isEnoent(error)) { + // File not found is expected when config doesn't exist on disk + return undefined; + } const code = getStatusFromError(error); - if (code !== 404) { - // 400 is expected when doesn't exist on disk - const summary = getSummaryStringFromError( - "getSelectedConfiguration, contentRecords.get", - error, - ); - window.showInformationMessage( - `Unable to retrieve deployment configuration: ${summary}`, - ); + if (code === 404) { + // 404 from interpreters API when project dir doesn't exist + return undefined; } + const summary = getSummaryStringFromError( + "getSelectedConfiguration, configStore.get", + error, + ); + window.showInformationMessage( + `Unable to retrieve deployment configuration: ${summary}`, + ); return undefined; } } @@ -398,3 +417,12 @@ export class PublisherState implements Disposable { return findCredentialForContentRecord(contentRecord, this.credentials); } } + +function isEnoent(error: unknown): boolean { + return ( + error !== null && + typeof error === "object" && + "code" in error && + (error as { code: unknown }).code === "ENOENT" + ); +} diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json index 38784b577..6d231851a 100644 --- a/extensions/vscode/tsconfig.json +++ b/extensions/vscode/tsconfig.json @@ -11,6 +11,7 @@ "sourceMap": true, "rootDir": "src", /* Linting */ + "resolveJsonModule": true, "strict": true, "noUnusedLocals": true, "noUnusedParameters": true,