From 5da8448bf5341bc336edf2f17195999e4bce0fc7 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Tue, 28 Apr 2026 11:00:29 -0700 Subject: [PATCH 1/2] Add support for environment switching for local dev --- esbuild.js | 8 ++ src/cloud/api.ts | 29 +++--- src/cloud/auth.ts | 18 ++-- src/cloud/controller.ts | 1 + src/cloud/ui/menus.ts | 5 +- src/env.ts | 90 ++++++++++++++++++ src/extension.ts | 11 ++- src/test/cloud/api.test.ts | 56 +++++------ src/test/cloud/auth.test.ts | 6 +- src/test/cloud/ui/menus.test.ts | 8 +- src/test/env.test.ts | 158 ++++++++++++++++++++++++++++++++ src/test/testUtils.ts | 10 ++ 12 files changed, 343 insertions(+), 57 deletions(-) create mode 100644 src/env.ts create mode 100644 src/test/env.test.ts diff --git a/esbuild.js b/esbuild.js index d29bedc..ba4a3a7 100644 --- a/esbuild.js +++ b/esbuild.js @@ -116,15 +116,23 @@ async function main() { // internally references these Node.js modules for environment detection // posthog-node uses Node.js APIs, so telemetry is disabled in browser // util and child_process are used for version detection but not in browser + // os/path/fs/promises are imported by env.ts; loadEnvironment's + // try/catch swallows the require() failure at runtime in vscode.dev, + // so the user falls back to default URLs in the browser environment external: [ "vscode", "fs/promises", + "node:fs/promises", "module", "posthog-node", "util", "child_process", + "os", + "path", "node:util", "node:child_process", + "node:os", + "node:path", ], }) diff --git a/src/cloud/api.ts b/src/cloud/api.ts index 06d78ad..d689cd8 100644 --- a/src/cloud/api.ts +++ b/src/cloud/api.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode" +import { DEFAULT_BASE_URL, DEFAULT_DASHBOARD_URL } from "../env" import { getExtensionVersion } from "../extension" import { log } from "../utils/logger" import { AUTH_PROVIDER_ID } from "./auth" @@ -11,9 +12,6 @@ import type { User, } from "./types" -export const BASE_URL = "https://api.fastapicloud.com/api/v1" -export const DASHBOARD_URL = "https://dashboard.fastapicloud.com" - function getUserAgentHeaders(): Record { if (vscode.env.uiKind === vscode.UIKind.Web) return {} return { "User-Agent": `fastapi-vscode/${getExtensionVersion()}` } @@ -33,8 +31,13 @@ export class StreamLogError extends Error { } export class ApiService { - static getDashboardUrl(teamSlug: string, appSlug: string): string { - return `${DASHBOARD_URL}/${teamSlug}/apps/${appSlug}/general` + constructor( + public readonly baseUrl: string = DEFAULT_BASE_URL, + public readonly dashboardUrl: string = DEFAULT_DASHBOARD_URL, + ) {} + + getDashboardUrl(teamSlug: string, appSlug: string): string { + return `${this.dashboardUrl}/${teamSlug}/apps/${appSlug}/general` } private async request( @@ -51,7 +54,7 @@ export class ApiService { } const token = session.accessToken - const response = await fetch(`${BASE_URL}${endpoint}`, { + const response = await fetch(`${this.baseUrl}${endpoint}`, { ...options, headers: { Authorization: `Bearer ${token}`, @@ -77,9 +80,9 @@ export class ApiService { return (await response.json()) as T } - static async getUser(token: string): Promise { + async getUser(token: string): Promise { try { - const response = await fetch(`${BASE_URL}/users/me`, { + const response = await fetch(`${this.baseUrl}/users/me`, { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", @@ -170,7 +173,7 @@ export class ApiService { follow: String(follow), }) const response = await fetch( - `${BASE_URL}/apps/${appId}/logs/stream?${params}`, + `${this.baseUrl}/apps/${appId}/logs/stream?${params}`, { headers: { Authorization: `Bearer ${session.accessToken}`, @@ -236,7 +239,7 @@ export class ApiService { } } - static async requestDeviceCode(clientId: string): Promise<{ + async requestDeviceCode(clientId: string): Promise<{ device_code: string user_code: string verification_uri: string @@ -244,7 +247,7 @@ export class ApiService { expires_in?: number interval?: number }> { - const response = await fetch(`${BASE_URL}/login/device/authorization`, { + const response = await fetch(`${this.baseUrl}/login/device/authorization`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -281,7 +284,7 @@ export class ApiService { } } - static async pollDeviceToken( + async pollDeviceToken( clientId: string, deviceCode: string, intervalMs = 5000, @@ -292,7 +295,7 @@ export class ApiService { throw new Error("Sign-in cancelled") } - const response = await fetch(`${BASE_URL}/login/device/token`, { + const response = await fetch(`${this.baseUrl}/login/device/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", diff --git a/src/cloud/auth.ts b/src/cloud/auth.ts index 0c4a3b4..d8061ed 100644 --- a/src/cloud/auth.ts +++ b/src/cloud/auth.ts @@ -15,7 +15,7 @@ import { workspace, } from "vscode" import { trackCloudSignIn } from "../utils/telemetry" -import { ApiService } from "./api" +import type { ApiService } from "./api" export const AUTH_PROVIDER_ID = "fastapi-vscode" export const NAME = "FastAPI Cloud" @@ -58,7 +58,10 @@ export class CloudAuthenticationProvider new EventEmitter() private _disposable: Disposable - constructor(private readonly context: ExtensionContext) { + constructor( + private readonly context: ExtensionContext, + private readonly apiService: ApiService, + ) { this._disposable = Disposable.from( authentication.registerAuthenticationProvider( AUTH_PROVIDER_ID, @@ -142,7 +145,7 @@ export class CloudAuthenticationProvider } if (!this.cachedLabel) { - const info = await ApiService.getUser(token) + const info = await this.apiService.getUser(token) if (info?.email) { this.cachedLabel = info.email } @@ -200,11 +203,10 @@ export class CloudAuthenticationProvider return sessions[0] } - let deviceCodeResponse: Awaited< - ReturnType - > + let deviceCodeResponse: Awaited> try { - deviceCodeResponse = await ApiService.requestDeviceCode(AUTH_PROVIDER_ID) + deviceCodeResponse = + await this.apiService.requestDeviceCode(AUTH_PROVIDER_ID) } catch (error) { if ( error instanceof TypeError && @@ -234,7 +236,7 @@ export class CloudAuthenticationProvider const abortController = new AbortController() cancellationToken.onCancellationRequested(() => abortController.abort()) - return await ApiService.pollDeviceToken( + return await this.apiService.pollDeviceToken( AUTH_PROVIDER_ID, deviceCodeResponse.device_code, intervalMs, diff --git a/src/cloud/controller.ts b/src/cloud/controller.ts index a0b9f97..83035d6 100644 --- a/src/cloud/controller.ts +++ b/src/cloud/controller.ts @@ -66,6 +66,7 @@ export class CloudController { deploy: (uri) => this.deploy(uri), viewLogs: () => this.viewLogs(), }, + this.apiService, ) } diff --git a/src/cloud/ui/menus.ts b/src/cloud/ui/menus.ts index 3a0da22..fa374e2 100644 --- a/src/cloud/ui/menus.ts +++ b/src/cloud/ui/menus.ts @@ -3,7 +3,7 @@ import { trackCloudAppOpened, trackCloudDashboardOpened, } from "../../utils/telemetry" -import { ApiService } from "../api" +import type { ApiService } from "../api" import { AUTH_PROVIDER_ID } from "../auth" import type { WorkspaceState } from "../types" import { ui } from "./dialogs" @@ -24,6 +24,7 @@ export class MenuHandler { private getState: (uri: vscode.Uri) => WorkspaceState, private getActiveWorkspaceFolder: () => vscode.Uri | null, private actions: MenuActions, + private apiService: ApiService, ) {} async showMenu(): Promise { @@ -65,7 +66,7 @@ export class MenuHandler { if (state.status !== "linked") return const { app, team } = state - const dashboardUrl = ApiService.getDashboardUrl(team.slug, app.slug) + const dashboardUrl = this.apiService.getDashboardUrl(team.slug, app.slug) const items = [ { label: "$(rocket) Deploy App", diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..c4ef05b --- /dev/null +++ b/src/env.ts @@ -0,0 +1,90 @@ +export const DEFAULT_BASE_URL = "https://api.fastapicloud.com/api/v1" +export const DEFAULT_DASHBOARD_URL = "https://dashboard.fastapicloud.com" + +/** + * Test injection seam for `loadEnvironment`. In production all fields default + * to real `os` / `fs` / `process.env` reads — overrides are for unit tests. + * + * In the browser (vscode.dev) the dynamic Node imports throw and the + * try/catch returns defaults, so this function is effectively a no-op there. + */ +export interface EnvironmentDeps { + homedir?: () => string + platform?: () => NodeJS.Platform + getAppData?: () => string | undefined + readFile?: (path: string) => Promise + pathJoin?: (...parts: string[]) => string +} + +export function deriveDashboardUrl(baseUrl: string): string { + try { + const url = new URL(baseUrl) + const dashboardHostname = url.hostname.replace(/^api\./, "dashboard.") + return `https://${dashboardHostname}` + } catch { + return DEFAULT_DASHBOARD_URL + } +} + +function buildConfigPath(deps: { + homedir: () => string + platform: () => NodeJS.Platform + getAppData: () => string | undefined + pathJoin: (...parts: string[]) => string +}): string { + const home = deps.homedir() + if (!home) { + throw new Error("Unable to determine home directory for config file") + } + const plat = deps.platform() + if (plat === "darwin") { + return deps.pathJoin( + home, + "Library", + "Application Support", + "fastapi-cli", + "cli.json", + ) + } + if (plat === "win32") { + return deps.pathJoin(deps.getAppData() || home, "fastapi-cli", "cli.json") + } + return deps.pathJoin(home, ".config", "fastapi-cli", "cli.json") +} + +export async function loadEnvironment( + deps: EnvironmentDeps = {}, +): Promise<{ baseUrl: string; dashboardUrl: string }> { + try { + // Dynamic imports so this module loads cleanly in the browser bundle + // (vscode.dev). On failure the catch returns defaults. + const os = await import("node:os") + const fsp = await import("node:fs/promises") + const path = await import("node:path") + + const homedir = deps.homedir ?? os.homedir + const platform = deps.platform ?? os.platform + const getAppData = deps.getAppData ?? (() => process.env.APPDATA) + const pathJoin = deps.pathJoin ?? path.join + const readFile = deps.readFile ?? ((p: string) => fsp.readFile(p, "utf-8")) + + const configPath = buildConfigPath({ + homedir, + platform, + getAppData, + pathJoin, + }) + const raw = await readFile(configPath) + const config = JSON.parse(raw) + const baseUrl = config.base_api_url || DEFAULT_BASE_URL + return { + baseUrl, + dashboardUrl: deriveDashboardUrl(baseUrl), + } + } catch { + return { + baseUrl: DEFAULT_BASE_URL, + dashboardUrl: DEFAULT_DASHBOARD_URL, + } + } +} diff --git a/src/extension.ts b/src/extension.ts index b06dc08..2f195e0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -14,6 +14,7 @@ import { Parser } from "./core/parser" import { stripLeadingDynamicSegments } from "./core/pathUtils" import { collectRoutes, countRouters } from "./core/treeUtils" import type { AppDefinition, SourceLocation } from "./core/types" +import { loadEnvironment } from "./env" import { disposeLogger, log } from "./utils/logger" import { createTimer, @@ -227,9 +228,14 @@ export async function activate(context: vscode.ExtensionContext) { .get("cloud.enabled", true) if (cloudEnabled) { + const env = await loadEnvironment() + + const configService = new ConfigService() + const apiService = new ApiService(env.baseUrl, env.dashboardUrl) + // Auth provider must be registered regardless of workspace, // so sign-in works from command palette and Accounts menu in vscode.dev - const authProvider = new CloudAuthenticationProvider(context) + const authProvider = new CloudAuthenticationProvider(context, apiService) authProvider.startWatching() context.subscriptions.push( @@ -241,9 +247,6 @@ export async function activate(context: vscode.ExtensionContext) { }), ) - const configService = new ConfigService() - const apiService = new ApiService() - const logsViewProvider = new LogsViewProvider( context.extensionUri, configService, diff --git a/src/test/cloud/api.test.ts b/src/test/cloud/api.test.ts index 8fd5897..4b63813 100644 --- a/src/test/cloud/api.test.ts +++ b/src/test/cloud/api.test.ts @@ -1,7 +1,8 @@ import * as assert from "node:assert" import sinon from "sinon" import * as vscode from "vscode" -import { ApiService, BASE_URL } from "../../cloud/api" +import { ApiService } from "../../cloud/api" +import { DEFAULT_BASE_URL } from "../../env" import { mockResponse } from "../testUtils" suite("cloud/api", () => { @@ -9,7 +10,7 @@ suite("cloud/api", () => { suite("getDashboardUrl", () => { test("returns correct URL", () => { - const url = ApiService.getDashboardUrl("my-team", "my-app") + const url = new ApiService().getDashboardUrl("my-team", "my-app") assert.strictEqual( url, "https://dashboard.fastapicloud.com/my-team/apps/my-app/general", @@ -30,7 +31,7 @@ suite("cloud/api", () => { }), ) - const result = await ApiService.requestDeviceCode("test-client") + const result = await new ApiService().requestDeviceCode("test-client") assert.deepStrictEqual(result, { device_code: "dc_123", @@ -42,7 +43,7 @@ suite("cloud/api", () => { }) const [url, options] = fetchStub.firstCall.args - assert.strictEqual(url, `${BASE_URL}/login/device/authorization`) + assert.strictEqual(url, `${DEFAULT_BASE_URL}/login/device/authorization`) assert.strictEqual(options?.method, "POST") assert.ok( (options?.headers as Record)["Content-Type"]?.includes( @@ -60,7 +61,7 @@ suite("cloud/api", () => { }), ) - const result = await ApiService.requestDeviceCode("test-client") + const result = await new ApiService().requestDeviceCode("test-client") assert.strictEqual(result.verification_uri_complete, "") assert.strictEqual(result.expires_in, undefined) @@ -71,7 +72,7 @@ suite("cloud/api", () => { sinon.stub(globalThis, "fetch").resolves(mockResponse({}, false, 500)) await assert.rejects( - () => ApiService.requestDeviceCode("test-client"), + () => new ApiService().requestDeviceCode("test-client"), /Device code request failed: 500/, ) }) @@ -80,7 +81,7 @@ suite("cloud/api", () => { sinon.stub(globalThis, "fetch").resolves(mockResponse({})) await assert.rejects( - () => ApiService.requestDeviceCode("test-client"), + () => new ApiService().requestDeviceCode("test-client"), /Invalid response from device code endpoint/, ) }) @@ -94,7 +95,7 @@ suite("cloud/api", () => { mockResponse({ email: "test@example.com", full_name: "Test User" }), ) - const result = await ApiService.getUser("test_token") + const result = await new ApiService().getUser("test_token") assert.deepStrictEqual(result, { email: "test@example.com", @@ -105,7 +106,7 @@ suite("cloud/api", () => { test("returns null on non-ok response", async () => { sinon.stub(globalThis, "fetch").resolves(mockResponse({}, false, 401)) - const result = await ApiService.getUser("bad_token") + const result = await new ApiService().getUser("bad_token") assert.strictEqual(result, null) }) @@ -113,7 +114,7 @@ suite("cloud/api", () => { test("returns null on network error", async () => { sinon.stub(globalThis, "fetch").rejects(new Error("fetch failed")) - const result = await ApiService.getUser("test_token") + const result = await new ApiService().getUser("test_token") assert.strictEqual(result, null) }) @@ -125,7 +126,10 @@ suite("cloud/api", () => { .stub(globalThis, "fetch") .resolves(mockResponse({ access_token: "test_token_123" })) - const result = await ApiService.pollDeviceToken("test-client", "dc_123") + const result = await new ApiService().pollDeviceToken( + "test-client", + "dc_123", + ) assert.strictEqual(result, "test_token_123") }) @@ -141,7 +145,7 @@ suite("cloud/api", () => { .onSecondCall() .resolves(mockResponse({ access_token: "token_after_poll" })) - const resultPromise = ApiService.pollDeviceToken( + const resultPromise = new ApiService().pollDeviceToken( "test-client", "dc_123", 100, @@ -163,7 +167,7 @@ suite("cloud/api", () => { .resolves(mockResponse({ error: "expired_token" }, false, 400)) await assert.rejects( - () => ApiService.pollDeviceToken("test-client", "dc_123"), + () => new ApiService().pollDeviceToken("test-client", "dc_123"), /Device code has expired/, ) }) @@ -174,7 +178,7 @@ suite("cloud/api", () => { .resolves(mockResponse({ error: "server_error" }, false, 500)) await assert.rejects( - () => ApiService.pollDeviceToken("test-client", "dc_123"), + () => new ApiService().pollDeviceToken("test-client", "dc_123"), /Device token request failed: server_error/, ) }) @@ -189,7 +193,7 @@ suite("cloud/api", () => { await assert.rejects( () => - ApiService.pollDeviceToken( + new ApiService().pollDeviceToken( "test-client", "dc_123", 100, @@ -243,18 +247,16 @@ suite("cloud/api", () => { }) test("throws with detail message from API error response", async () => { - sinon - .stub(globalThis, "fetch") - .resolves( - mockResponse( - { - detail: - "App limit reached (3). Upgrade your plan to create more apps.", - }, - false, - 403, - ), - ) + sinon.stub(globalThis, "fetch").resolves( + mockResponse( + { + detail: + "App limit reached (3). Upgrade your plan to create more apps.", + }, + false, + 403, + ), + ) await assert.rejects( () => api.createApp("team-id", "New App"), diff --git a/src/test/cloud/auth.test.ts b/src/test/cloud/auth.test.ts index bf0ee89..ca88ebd 100644 --- a/src/test/cloud/auth.test.ts +++ b/src/test/cloud/auth.test.ts @@ -1,6 +1,7 @@ import * as assert from "node:assert" import sinon from "sinon" import * as vscode from "vscode" +import { ApiService } from "../../cloud/api" import { CloudAuthenticationProvider, isTokenExpired, @@ -106,8 +107,9 @@ suite("cloud/auth", () => { function createProvider() { const context = createMockContext() - const provider = new CloudAuthenticationProvider(context) - return { provider, context } + const apiService = new ApiService() + const provider = new CloudAuthenticationProvider(context, apiService) + return { provider, context, apiService } } suite("getSessions", () => { diff --git a/src/test/cloud/ui/menus.test.ts b/src/test/cloud/ui/menus.test.ts index 353af02..4b6a42d 100644 --- a/src/test/cloud/ui/menus.test.ts +++ b/src/test/cloud/ui/menus.test.ts @@ -4,6 +4,7 @@ import * as vscode from "vscode" import type { WorkspaceState } from "../../../cloud/types" import { ui } from "../../../cloud/ui/dialogs" import { type MenuActions, MenuHandler } from "../../../cloud/ui/menus" +import { mockApiService } from "../../testUtils" const mockSession = { accessToken: "test_token", @@ -27,7 +28,12 @@ function createMenuHandler( viewLogs: sinon.stub().resolves(), } - const handler = new MenuHandler(getState, getActiveWorkspaceFolder, actions) + const handler = new MenuHandler( + getState, + getActiveWorkspaceFolder, + actions, + mockApiService(), + ) return { handler, actions } } diff --git a/src/test/env.test.ts b/src/test/env.test.ts new file mode 100644 index 0000000..a103bc1 --- /dev/null +++ b/src/test/env.test.ts @@ -0,0 +1,158 @@ +import * as assert from "node:assert" +import * as path from "node:path" +import sinon from "sinon" +import { + DEFAULT_BASE_URL, + DEFAULT_DASHBOARD_URL, + deriveDashboardUrl, + type EnvironmentDeps, + loadEnvironment, +} from "../env" + +function makeDeps(overrides: EnvironmentDeps = {}): Required { + return { + homedir: () => "/Users/test", + platform: () => "darwin" as NodeJS.Platform, + getAppData: () => undefined, + readFile: sinon.stub().rejects(new Error("ENOENT")), + pathJoin: path.join, + ...overrides, + } +} + +suite("env/loadEnvironment", () => { + suite("config path resolution", () => { + test("macOS uses Library/Application Support", async () => { + const readFile = sinon.stub().rejects(new Error("ENOENT")) + await loadEnvironment(makeDeps({ platform: () => "darwin", readFile })) + + assert.strictEqual( + readFile.firstCall.args[0], + "/Users/test/Library/Application Support/fastapi-cli/cli.json", + ) + }) + + test("Linux uses ~/.config", async () => { + const readFile = sinon.stub().rejects(new Error("ENOENT")) + await loadEnvironment( + makeDeps({ + platform: () => "linux", + homedir: () => "/home/test", + readFile, + }), + ) + + assert.strictEqual( + readFile.firstCall.args[0], + "/home/test/.config/fastapi-cli/cli.json", + ) + }) + + test("Windows uses APPDATA", async () => { + const readFile = sinon.stub().rejects(new Error("ENOENT")) + await loadEnvironment( + makeDeps({ + platform: () => "win32", + homedir: () => "C:\\Users\\test", + getAppData: () => "C:\\Users\\test\\AppData\\Roaming", + readFile, + }), + ) + + const callPath = readFile.firstCall.args[0] as string + assert.ok(callPath.includes("fastapi-cli")) + assert.ok(callPath.includes("AppData")) + }) + + test("Windows falls back to home when APPDATA missing", async () => { + const readFile = sinon.stub().rejects(new Error("ENOENT")) + await loadEnvironment( + makeDeps({ + platform: () => "win32", + homedir: () => "C:\\Users\\test", + getAppData: () => undefined, + readFile, + }), + ) + + const callPath = readFile.firstCall.args[0] as string + assert.ok(callPath.includes("C:\\Users\\test")) + assert.ok(!callPath.includes("AppData")) + }) + }) + + suite("config parsing", () => { + test("returns defaults when file missing", async () => { + const env = await loadEnvironment(makeDeps()) + + assert.strictEqual(env.baseUrl, DEFAULT_BASE_URL) + assert.strictEqual(env.dashboardUrl, DEFAULT_DASHBOARD_URL) + }) + + test("returns defaults when JSON is invalid", async () => { + const env = await loadEnvironment( + makeDeps({ readFile: async () => "not json {{{" }), + ) + + assert.strictEqual(env.baseUrl, DEFAULT_BASE_URL) + assert.strictEqual(env.dashboardUrl, DEFAULT_DASHBOARD_URL) + }) + + test("returns defaults when base_api_url is missing", async () => { + const env = await loadEnvironment( + makeDeps({ + readFile: async () => JSON.stringify({ other_field: "foo" }), + }), + ) + + assert.strictEqual(env.baseUrl, DEFAULT_BASE_URL) + assert.strictEqual(env.dashboardUrl, DEFAULT_DASHBOARD_URL) + }) + + test("uses configured base_api_url", async () => { + const env = await loadEnvironment( + makeDeps({ + readFile: async () => + JSON.stringify({ + base_api_url: "https://api.localfastapicloud.com/api/v1", + }), + }), + ) + + assert.strictEqual( + env.baseUrl, + "https://api.localfastapicloud.com/api/v1", + ) + }) + }) + + suite("deriveDashboardUrl", () => { + test("replaces api. prefix with dashboard.", () => { + assert.strictEqual( + deriveDashboardUrl("https://api.localfastapicloud.com/api/v1"), + "https://dashboard.localfastapicloud.com", + ) + }) + + test("derives prod dashboard from prod api", () => { + assert.strictEqual( + deriveDashboardUrl("https://api.fastapicloud.com/api/v1"), + "https://dashboard.fastapicloud.com", + ) + }) + + test("leaves non-api. hostnames unchanged (current behavior)", () => { + // Bug-catching test: the regex requires ^api., so anything else + // produces an unchanged hostname. Update this test if support for + // other prefixes (e.g. staging-api) is ever added. + assert.strictEqual( + deriveDashboardUrl("https://staging-api.fastapicloud.com/api/v1"), + "https://staging-api.fastapicloud.com", + ) + }) + + test("falls back to default when URL is malformed", () => { + assert.strictEqual(deriveDashboardUrl("not a url"), DEFAULT_DASHBOARD_URL) + }) + }) +}) diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index a078b4e..631d036 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -187,6 +187,14 @@ export function mockResponse(body: unknown, ok = true, status = 200): Response { export function mockApiService(overrides?: Partial) { return { + baseUrl: "https://api.fastapicloud.com/api/v1", + dashboardUrl: "https://dashboard.fastapicloud.com", + getDashboardUrl: sinon + .stub() + .callsFake( + (teamSlug: string, appSlug: string) => + `https://dashboard.fastapicloud.com/${teamSlug}/apps/${appSlug}/general`, + ), getUser: sinon.stub().resolves(null), getTeams: sinon.stub().resolves([]), getApps: sinon.stub().resolves([]), @@ -197,6 +205,8 @@ export function mockApiService(overrides?: Partial) { getUploadUrl: sinon.stub(), completeUpload: sinon.stub(), getDeployment: sinon.stub(), + requestDeviceCode: sinon.stub(), + pollDeviceToken: sinon.stub(), ...overrides, } as unknown as sinon.SinonStubbedInstance } From 1b7dcfa3ca5238411fb05e65f33efdf8381b55ad Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Tue, 28 Apr 2026 11:15:51 -0700 Subject: [PATCH 2/2] Improve stub usage --- src/test/cloud/ui/pickers.test.ts | 45 +++++++++++++------------------ src/test/testUtils.ts | 41 +++++++++++++--------------- 2 files changed, 36 insertions(+), 50 deletions(-) diff --git a/src/test/cloud/ui/pickers.test.ts b/src/test/cloud/ui/pickers.test.ts index 7fea1b8..2693ac7 100644 --- a/src/test/cloud/ui/pickers.test.ts +++ b/src/test/cloud/ui/pickers.test.ts @@ -157,9 +157,8 @@ suite("cloud/ui/pickers", () => { suite("pickTeam", () => { test("auto-selects when only one team", async () => { - const api = mockApiService({ - getTeams: sinon.stub().resolves([team1]), - }) + const api = mockApiService() + api.getTeams.resolves([team1]) const result = await pickTeam(api) @@ -167,9 +166,8 @@ suite("cloud/ui/pickers", () => { }) test("shows quick pick when multiple teams", async () => { - const api = mockApiService({ - getTeams: sinon.stub().resolves([team1, team2]), - }) + const api = mockApiService() + api.getTeams.resolves([team1, team2]) sinon .stub(ui, "showQuickPick") @@ -191,9 +189,8 @@ suite("cloud/ui/pickers", () => { }) test("returns null on fetch error", async () => { - const api = mockApiService({ - getTeams: sinon.stub().rejects(new Error("Network error")), - }) + const api = mockApiService() + api.getTeams.rejects(new Error("Network error")) const errorStub = sinon.stub(ui, "showErrorMessage") const result = await pickTeam(api) @@ -203,9 +200,8 @@ suite("cloud/ui/pickers", () => { }) test("returns null when user cancels", async () => { - const api = mockApiService({ - getTeams: sinon.stub().resolves([team1, team2]), - }) + const api = mockApiService() + api.getTeams.resolves([team1, team2]) sinon.stub(ui, "showQuickPick").resolves(undefined) @@ -217,9 +213,8 @@ suite("cloud/ui/pickers", () => { suite("pickExistingApp", () => { test("shows apps and returns selection", async () => { - const api = mockApiService({ - getApps: sinon.stub().resolves([app1, app2]), - }) + const api = mockApiService() + api.getApps.resolves([app1, app2]) sinon .stub(vscode.window, "showQuickPick") @@ -241,9 +236,8 @@ suite("cloud/ui/pickers", () => { }) test("returns null on fetch error", async () => { - const api = mockApiService({ - getApps: sinon.stub().rejects(new Error("Network error")), - }) + const api = mockApiService() + api.getApps.rejects(new Error("Network error")) const errorStub = sinon.stub(ui, "showErrorMessage") const result = await pickExistingApp(api, team1) @@ -253,9 +247,8 @@ suite("cloud/ui/pickers", () => { }) test("returns null when user cancels", async () => { - const api = mockApiService({ - getApps: sinon.stub().resolves([app1]), - }) + const api = mockApiService() + api.getApps.resolves([app1]) sinon.stub(ui, "showQuickPick").resolves(undefined) @@ -268,9 +261,8 @@ suite("cloud/ui/pickers", () => { suite("createNewApp", () => { test("creates app with valid name", async () => { const createdApp = { id: "a3", slug: "my-app", url: "", team_id: "t1" } - const api = mockApiService({ - createApp: sinon.stub().resolves(createdApp), - }) + const api = mockApiService() + api.createApp.resolves(createdApp) sinon.stub(vscode.window, "showInputBox").resolves("my-app") @@ -316,9 +308,8 @@ suite("cloud/ui/pickers", () => { }) test("returns null on API error", async () => { - const api = mockApiService({ - createApp: sinon.stub().rejects(new Error("Already exists")), - }) + const api = mockApiService() + api.createApp.rejects(new Error("Already exists")) sinon.stub(vscode.window, "showInputBox").resolves("my-app") const errorStub = sinon.stub(ui, "showErrorMessage") diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 631d036..e9564fa 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs" import { dirname, join } from "node:path" import sinon from "sinon" import * as vscode from "vscode" -import type { ApiService } from "../cloud/api" +import { ApiService } from "../cloud/api" import type { ConfigService } from "../cloud/config" import type { FileSystem } from "../core/filesystem" @@ -185,30 +185,25 @@ export function mockResponse(body: unknown, ok = true, status = 200): Response { } as unknown as Response } -export function mockApiService(overrides?: Partial) { - return { +export function mockApiService( + overrides: Partial> = {}, +): sinon.SinonStubbedInstance { + const stub = sinon.createStubInstance(ApiService) + Object.assign(stub, { baseUrl: "https://api.fastapicloud.com/api/v1", dashboardUrl: "https://dashboard.fastapicloud.com", - getDashboardUrl: sinon - .stub() - .callsFake( - (teamSlug: string, appSlug: string) => - `https://dashboard.fastapicloud.com/${teamSlug}/apps/${appSlug}/general`, - ), - getUser: sinon.stub().resolves(null), - getTeams: sinon.stub().resolves([]), - getApps: sinon.stub().resolves([]), - createApp: sinon.stub(), - getApp: sinon.stub(), - getTeam: sinon.stub(), - createDeployment: sinon.stub(), - getUploadUrl: sinon.stub(), - completeUpload: sinon.stub(), - getDeployment: sinon.stub(), - requestDeviceCode: sinon.stub(), - pollDeviceToken: sinon.stub(), - ...overrides, - } as unknown as sinon.SinonStubbedInstance + }) + + stub.getDashboardUrl.callsFake((teamSlug: string, appSlug: string) => { + return `https://dashboard.fastapicloud.com/${teamSlug}/apps/${appSlug}/general` + }) + + stub.getUser.resolves(null) + stub.getTeams.resolves([]) + stub.getApps.resolves([]) + + Object.assign(stub, overrides) + return stub } export function mockConfigService() {