From 3ef769095fb8a62747e2a77242c3228f94c1b16d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:22:49 +0000 Subject: [PATCH 01/15] feat: add BINARY_TEST_MODE to testkit for spawning real CLI binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When BINARY_TEST_MODE=1, the testkit: - Starts a real Express HTTP server (BinaryAPIServer) instead of MSW - Spawns bin/run.js via execa with BASE44_API_URL pointing at the server - Skips MSW server lifecycle in setupCLITests() All 21 existing test spec files are unchanged — the TestContext interface is fully preserved. The migration requires only setting BINARY_TEST_MODE=1. Co-authored-by: Kfir Stri --- .../cli/tests/cli/testkit/BinaryAPIServer.ts | 489 ++++++++++++++++++ packages/cli/tests/cli/testkit/CLITestkit.ts | 59 ++- packages/cli/tests/cli/testkit/index.ts | 15 +- 3 files changed, 556 insertions(+), 7 deletions(-) create mode 100644 packages/cli/tests/cli/testkit/BinaryAPIServer.ts diff --git a/packages/cli/tests/cli/testkit/BinaryAPIServer.ts b/packages/cli/tests/cli/testkit/BinaryAPIServer.ts new file mode 100644 index 00000000..38d437c4 --- /dev/null +++ b/packages/cli/tests/cli/testkit/BinaryAPIServer.ts @@ -0,0 +1,489 @@ +import type { Server } from "node:http"; +import { createServer } from "node:http"; +import express from "express"; +import getPort from "get-port"; + +// ─── RESPONSE TYPES ────────────────────────────────────────── +// (same as Base44APIMock for interface parity) + +interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} + +interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; +} + +interface UserInfoResponse { + email: string; + name?: string; +} + +interface EntitiesPushResponse { + created: string[]; + updated: string[]; + deleted: string[]; +} + +interface FunctionsPushResponse { + deployed: string[]; + deleted: string[]; + errors: Array<{ name: string; message: string }> | null; +} + +interface SiteDeployResponse { + app_url: string; +} + +interface SiteUrlResponse { + url: string; +} + +interface AgentsPushResponse { + created: string[]; + updated: string[]; + deleted: string[]; +} + +interface AgentsFetchResponse { + items: Array<{ name: string; [key: string]: unknown }>; + total: number; +} + +interface FunctionLogEntry { + time: string; + level: "info" | "warning" | "error" | "debug"; + message: string; +} + +type FunctionLogsResponse = FunctionLogEntry[]; + +type SecretsListResponse = Record; + +interface SecretsSetResponse { + success: boolean; +} + +interface SecretsDeleteResponse { + success: boolean; +} + +interface ConnectorsListResponse { + integrations: Array<{ + integration_type: string; + status: string; + scopes: string[]; + user_email?: string; + }>; +} + +interface ConnectorSetResponse { + redirect_url: string | null; + connection_id: string | null; + already_authorized: boolean; + error?: "different_user"; + error_message?: string; + other_user_email?: string; +} + +interface ConnectorRemoveResponse { + status: "removed"; + integration_type: string; +} + +interface CreateAppResponse { + id: string; + name: string; +} + +interface ListProjectsResponse { + id: string; + name: string; + user_description?: string | null; + is_managed_source_code?: boolean; +} + +interface ErrorResponse { + status: number; + body?: unknown; +} + +// ─── ROUTE HANDLER TYPES ───────────────────────────────────── + +type Method = "GET" | "POST" | "PUT" | "DELETE"; + +interface RouteEntry { + method: Method; + /** Express path pattern, e.g. /api/apps/:appId/entity-schemas */ + path: string; + handler: (req: express.Request, res: express.Response) => void; +} + +// ─── SERVER CLASS ──────────────────────────────────────────── + +/** + * Real HTTP server that replaces MSW for binary integration tests. + * + * Exposes the same `mock*` interface as `Base44APIMock` so test files + * are unchanged when switching between in-process and binary modes. + * + * @example + * ```typescript + * const server = new BinaryAPIServer("my-app-id"); + * await server.start(); + * + * server.mockEntitiesPush({ created: ["User"], updated: [], deleted: [] }); + * server.apply(); // register routes before spawning the binary + * + * // binary is spawned with BASE44_API_URL=http://localhost: + * + * await server.stop(); + * ``` + */ +export class BinaryAPIServer { + private pendingRoutes: RouteEntry[] = []; + private app: express.Application; + private server: Server | null = null; + private _port = 0; + + constructor(readonly appId: string) { + this.app = express(); + this.app.use(express.json()); + this.app.use(express.raw({ type: "*/*", limit: "50mb" })); + } + + get baseUrl(): string { + return `http://localhost:${this._port}`; + } + + get port(): number { + return this._port; + } + + // ─── LIFECYCLE ─────────────────────────────────────────── + + async start(): Promise { + this._port = await getPort(); + await new Promise((resolve, reject) => { + this.server = createServer(this.app); + this.server.listen(this._port, "127.0.0.1", () => resolve()); + this.server.on("error", reject); + }); + } + + async stop(): Promise { + if (this.server) { + await new Promise((resolve, reject) => { + this.server!.close((err) => (err ? reject(err) : resolve())); + }); + this.server = null; + } + this.pendingRoutes = []; + } + + /** + * Register all accumulated mock routes on the Express app. + * Call this after all mock* methods, before spawning the binary. + */ + apply(): void { + for (const entry of this.pendingRoutes) { + const method = entry.method.toLowerCase() as + | "get" + | "post" + | "put" + | "delete"; + this.app[method](entry.path, entry.handler); + } + this.pendingRoutes = []; + } + + // ─── PRIVATE HELPERS ───────────────────────────────────── + + private addRoute( + method: Method, + path: string, + body: unknown, + status = 200, + ): this { + this.pendingRoutes.push({ + method, + path, + handler: (_req, res) => { + res.status(status).json(body); + }, + }); + return this; + } + + private addBinaryRoute( + method: Method, + path: string, + data: Uint8Array, + contentType: string, + ): this { + this.pendingRoutes.push({ + method, + path, + handler: (_req, res) => { + res.setHeader("Content-Type", contentType); + res.status(200).send(Buffer.from(data)); + }, + }); + return this; + } + + private addErrorRoute( + method: Method, + path: string, + error: ErrorResponse, + ): this { + return this.addRoute( + method, + path, + error.body ?? { error: "Error" }, + error.status, + ); + } + + // ─── AUTH ENDPOINTS ────────────────────────────────────── + + mockDeviceCode(response: DeviceCodeResponse): this { + return this.addRoute("POST", "/oauth/device/code", response); + } + + mockToken(response: TokenResponse): this { + return this.addRoute("POST", "/oauth/token", response); + } + + mockUserInfo(response: UserInfoResponse): this { + return this.addRoute("GET", "/oauth/userinfo", response); + } + + // ─── APP-SCOPED ENDPOINTS ──────────────────────────────── + + mockEntitiesPush(response: EntitiesPushResponse): this { + return this.addRoute( + "PUT", + `/api/apps/${this.appId}/entity-schemas`, + response, + ); + } + + mockFunctionsPush(response: FunctionsPushResponse): this { + return this.addRoute( + "PUT", + `/api/apps/${this.appId}/backend-functions`, + response, + ); + } + + mockSiteDeploy(response: SiteDeployResponse): this { + return this.addRoute( + "POST", + `/api/apps/${this.appId}/deploy-dist`, + response, + ); + } + + mockSiteUrl(response: SiteUrlResponse): this { + return this.addRoute( + "GET", + `/api/apps/platform/${this.appId}/published-url`, + response, + ); + } + + mockAgentsPush(response: AgentsPushResponse): this { + return this.addRoute( + "PUT", + `/api/apps/${this.appId}/agent-configs`, + response, + ); + } + + mockAgentsFetch(response: AgentsFetchResponse): this { + return this.addRoute( + "GET", + `/api/apps/${this.appId}/agent-configs`, + response, + ); + } + + // ─── CONNECTOR ENDPOINTS ───────────────────────────────── + + mockConnectorsList(response: ConnectorsListResponse): this { + return this.addRoute( + "GET", + `/api/apps/${this.appId}/external-auth/list`, + response, + ); + } + + mockConnectorSet(response: ConnectorSetResponse): this { + return this.addRoute( + "PUT", + `/api/apps/${this.appId}/external-auth/integrations/:type`, + { + error: null, + error_message: null, + other_user_email: null, + ...response, + }, + ); + } + + mockConnectorRemove(response: ConnectorRemoveResponse): this { + return this.addRoute( + "DELETE", + `/api/apps/${this.appId}/external-auth/integrations/:type/remove`, + response, + ); + } + + mockFunctionLogs(functionName: string, response: FunctionLogsResponse): this { + return this.addRoute( + "GET", + `/api/apps/${this.appId}/functions-mgmt/${functionName}/logs`, + response, + ); + } + + // ─── SECRETS ENDPOINTS ─────────────────────────────────── + + mockSecretsList(response: SecretsListResponse): this { + return this.addRoute("GET", `/api/apps/${this.appId}/secrets`, response); + } + + mockSecretsSet(response: SecretsSetResponse): this { + return this.addRoute("POST", `/api/apps/${this.appId}/secrets`, response); + } + + mockSecretsDelete(response: SecretsDeleteResponse): this { + return this.addRoute("DELETE", `/api/apps/${this.appId}/secrets`, response); + } + + // ─── GENERAL ENDPOINTS ─────────────────────────────────── + + mockCreateApp(response: CreateAppResponse): this { + return this.addRoute("POST", "/api/apps", response); + } + + mockListProjects(response: ListProjectsResponse[]): this { + return this.addRoute("GET", "/api/apps", response); + } + + mockProjectEject(tarContent: Uint8Array = new Uint8Array()): this { + return this.addBinaryRoute( + "GET", + `/api/apps/${this.appId}/eject`, + tarContent, + "application/gzip", + ); + } + + // ─── ERROR RESPONSES ───────────────────────────────────── + + mockError( + method: "get" | "post" | "put" | "delete", + path: string, + error: ErrorResponse, + ): this { + const fullPath = path.startsWith("/") ? path : `/${path}`; + return this.addErrorRoute(method.toUpperCase() as Method, fullPath, error); + } + + mockEntitiesPushError(error: ErrorResponse): this { + return this.addErrorRoute( + "PUT", + `/api/apps/${this.appId}/entity-schemas`, + error, + ); + } + + mockFunctionsPushError(error: ErrorResponse): this { + return this.addErrorRoute( + "PUT", + `/api/apps/${this.appId}/backend-functions`, + error, + ); + } + + mockSiteDeployError(error: ErrorResponse): this { + return this.addErrorRoute( + "POST", + `/api/apps/${this.appId}/deploy-dist`, + error, + ); + } + + mockSiteUrlError(error: ErrorResponse): this { + return this.addErrorRoute( + "GET", + `/api/apps/platform/${this.appId}/published-url`, + error, + ); + } + + mockAgentsPushError(error: ErrorResponse): this { + return this.addErrorRoute( + "PUT", + `/api/apps/${this.appId}/agent-configs`, + error, + ); + } + + mockAgentsFetchError(error: ErrorResponse): this { + return this.addErrorRoute( + "GET", + `/api/apps/${this.appId}/agent-configs`, + error, + ); + } + + mockFunctionLogsError(functionName: string, error: ErrorResponse): this { + return this.addErrorRoute( + "GET", + `/api/apps/${this.appId}/functions-mgmt/${functionName}/logs`, + error, + ); + } + + mockSecretsListError(error: ErrorResponse): this { + return this.addErrorRoute("GET", `/api/apps/${this.appId}/secrets`, error); + } + + mockSecretsSetError(error: ErrorResponse): this { + return this.addErrorRoute("POST", `/api/apps/${this.appId}/secrets`, error); + } + + mockSecretsDeleteError(error: ErrorResponse): this { + return this.addErrorRoute( + "DELETE", + `/api/apps/${this.appId}/secrets`, + error, + ); + } + + mockConnectorsListError(error: ErrorResponse): this { + return this.addErrorRoute( + "GET", + `/api/apps/${this.appId}/external-auth/list`, + error, + ); + } + + mockConnectorSetError(error: ErrorResponse): this { + return this.addErrorRoute( + "PUT", + `/api/apps/${this.appId}/external-auth/integrations/:type`, + error, + ); + } +} diff --git a/packages/cli/tests/cli/testkit/CLITestkit.ts b/packages/cli/tests/cli/testkit/CLITestkit.ts index 0e2a8efd..742f97b0 100644 --- a/packages/cli/tests/cli/testkit/CLITestkit.ts +++ b/packages/cli/tests/cli/testkit/CLITestkit.ts @@ -3,15 +3,21 @@ import { access, cp, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { Command } from "commander"; +import { execa } from "execa"; import { dir } from "tmp-promise"; import { vi } from "vitest"; import { Base44APIMock } from "./Base44APIMock.js"; +import { BinaryAPIServer } from "./BinaryAPIServer.js"; import type { CLIResult } from "./CLIResultMatcher.js"; import { CLIResultMatcher } from "./CLIResultMatcher.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const DIST_INDEX_PATH = join(__dirname, "../../../dist/cli/index.js"); const DIST_ASSETS_DIR = join(__dirname, "../../../dist/assets"); +const BIN_RUN_PATH = join(__dirname, "../../../bin/run.js"); + +/** When true, tests spawn the real binary instead of importing in-process */ +const BINARY_TEST_MODE = process.env.BINARY_TEST_MODE === "1"; /** Type for CLIContext */ interface CLIContext { @@ -42,17 +48,18 @@ export class CLITestkit { // Default latestVersion to null to skip npm version check in tests private testOverrides: TestOverrides = { latestVersion: null }; - /** Typed API mock for Base44 endpoints */ - readonly api: Base44APIMock; + /** Typed API mock for Base44 endpoints (MSW in-process or Express HTTP) */ + readonly api: Base44APIMock | BinaryAPIServer; private constructor( tempDir: string, cleanupFn: () => Promise, appId: string, + api: Base44APIMock | BinaryAPIServer, ) { this.tempDir = tempDir; this.cleanupFn = cleanupFn; - this.api = new Base44APIMock(appId); + this.api = api; // Set HOME to temp dir for auth file isolation // Set CI to prevent browser opens during tests // Disable telemetry to prevent error reporting during tests @@ -62,7 +69,13 @@ export class CLITestkit { /** Factory method - creates isolated test environment */ static async create(appId = "test-app-id"): Promise { const { path, cleanup } = await dir({ unsafeCleanup: true }); - return new CLITestkit(path, cleanup, appId); + const api = BINARY_TEST_MODE + ? new BinaryAPIServer(appId) + : new Base44APIMock(appId); + if (api instanceof BinaryAPIServer) { + await api.start(); + } + return new CLITestkit(path, cleanup, appId, api); } /** Get the temp directory path */ @@ -108,6 +121,41 @@ export class CLITestkit { /** Execute CLI command */ async run(...args: string[]): Promise { + if (this.api instanceof BinaryAPIServer) { + return this.runBinary(args); + } + return this.runInProcess(args); + } + + /** Spawn the real binary as a subprocess */ + private async runBinary(args: string[]): Promise { + this.setupEnvOverrides(); + + const env: Record = { + ...this.env, + BASE44_API_URL: (this.api as BinaryAPIServer).baseUrl, + PATH: process.env.PATH ?? "", + }; + + // Register all pending mock routes before spawning the binary + (this.api as BinaryAPIServer).apply(); + + const result = await execa("node", [BIN_RUN_PATH, ...args], { + cwd: this.projectDir ?? this.tempDir, + env, + reject: false, + all: false, + }); + + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode ?? 1, + }; + } + + /** Import and run CLI in-process (original behavior) */ + private async runInProcess(args: string[]): Promise { // Setup mocks this.setupCwdMock(); this.setupEnvOverrides(); @@ -317,6 +365,9 @@ export class CLITestkit { // ─── CLEANUP ────────────────────────────────────────────────── async cleanup(): Promise { + if (this.api instanceof BinaryAPIServer) { + await this.api.stop(); + } await this.cleanupFn(); } } diff --git a/packages/cli/tests/cli/testkit/index.ts b/packages/cli/tests/cli/testkit/index.ts index 24ff6c6f..bcbd4bf5 100644 --- a/packages/cli/tests/cli/testkit/index.ts +++ b/packages/cli/tests/cli/testkit/index.ts @@ -6,6 +6,8 @@ import { CLITestkit } from "./CLITestkit.js"; const FIXTURES_DIR = resolve(__dirname, "../../fixtures"); +const BINARY_TEST_MODE = process.env.BINARY_TEST_MODE === "1"; + export const mswServer = setupServer(); /** Resolve a fixture path by name */ @@ -93,7 +95,9 @@ export function setupCLITests(): TestContext { }; beforeAll(() => { - mswServer.listen({ onUnhandledRequest: "bypass" }); + if (!BINARY_TEST_MODE) { + mswServer.listen({ onUnhandledRequest: "bypass" }); + } }); beforeEach(async () => { @@ -101,7 +105,9 @@ export function setupCLITests(): TestContext { }); afterEach(async () => { - mswServer.resetHandlers(); + if (!BINARY_TEST_MODE) { + mswServer.resetHandlers(); + } if (currentKit) { await currentKit.cleanup(); currentKit = null; @@ -109,7 +115,9 @@ export function setupCLITests(): TestContext { }); afterAll(() => { - mswServer.close(); + if (!BINARY_TEST_MODE) { + mswServer.close(); + } }); // Default user for givenLoggedInWithProject @@ -147,6 +155,7 @@ export function setupCLITests(): TestContext { } export { Base44APIMock } from "./Base44APIMock.js"; +export { BinaryAPIServer } from "./BinaryAPIServer.js"; export type { CLIResult } from "./CLIResultMatcher.js"; export { CLIResultMatcher } from "./CLIResultMatcher.js"; // Re-export types and classes that tests might need From 83b0d404dd739541f41cee501daebad0ac054efa Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:25:45 +0000 Subject: [PATCH 02/15] refactor(testkit): always use compiled binary for CLI tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove BINARY_TEST_MODE env var — binary mode is now the only mode - Replace `node bin/run.js` with the platform-specific compiled binary from `dist/binaries/` (produced by `bun run build:binaries`) - Add `mockRoute()` to BinaryAPIServer for custom/stateful Express handlers - Remove unused `port` getter and `mockError` method (fixes knip errors) - Remove in-process (MSW) test path and related helpers from CLITestkit - Remove MSW lifecycle hooks (mswServer) from testkit index - Rewrite authorization.spec.ts to use BinaryAPIServer.mockRoute() instead of mswServer for the token-refresh-on-401 scenarios Co-authored-by: Kfir Stri --- packages/cli/tests/cli/authorization.spec.ts | 109 ++++----- .../cli/tests/cli/testkit/BinaryAPIServer.ts | 22 +- packages/cli/tests/cli/testkit/CLITestkit.ts | 225 ++---------------- packages/cli/tests/cli/testkit/index.ts | 22 +- 4 files changed, 83 insertions(+), 295 deletions(-) diff --git a/packages/cli/tests/cli/authorization.spec.ts b/packages/cli/tests/cli/authorization.spec.ts index 7463499c..20874d60 100644 --- a/packages/cli/tests/cli/authorization.spec.ts +++ b/packages/cli/tests/cli/authorization.spec.ts @@ -1,59 +1,48 @@ -import { HttpResponse, http } from "msw"; import { describe, it } from "vitest"; -import { fixture, mswServer, setupCLITests } from "./testkit/index.js"; +import { fixture, setupCLITests } from "./testkit/index.js"; -const BASE_URL = "https://app.base44.com"; const APP_ID = "test-app-id"; -function mockTokenRefresh() { - mswServer.use( - http.post(`${BASE_URL}/oauth/token`, () => - HttpResponse.json({ - access_token: "refreshed-access-token", - refresh_token: "refreshed-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }), - ), - ); -} +describe("token refresh on 401", () => { + const t = setupCLITests(); + + function mockTokenRefresh() { + t.api.mockToken({ + access_token: "refreshed-access-token", + refresh_token: "refreshed-refresh-token", + expires_in: 3600, + token_type: "Bearer", + }); + } -/** - * Creates an MSW handler that returns 401 on the first call, - * then delegates to a success handler on subsequent calls. - */ -function firstCall401ThenSuccess( - method: "put" | "post", - url: string, - successBody: Record, -) { - let callCount = 0; - mswServer.use( - http[method](url, () => { + /** + * Registers a handler that returns 401 on the first call, + * then returns the success body on subsequent calls. + */ + function firstCall401ThenSuccess( + method: "PUT" | "POST", + path: string, + successBody: Record, + ) { + let callCount = 0; + t.api.mockRoute(method, path, (_req, res) => { callCount++; if (callCount === 1) { - return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }); + res.status(401).json({ error: "Unauthorized" }); + } else { + res.status(200).json(successBody); } - return HttpResponse.json(successBody); - }), - ); -} - -describe("token refresh on 401", () => { - const t = setupCLITests(); + }); + } it("retries PUT with json body after 401 token refresh", async () => { await t.givenLoggedInWithProject(fixture("with-agents")); mockTokenRefresh(); - firstCall401ThenSuccess( - "put", - `${BASE_URL}/api/apps/${APP_ID}/agent-configs`, - { - created: ["customer_support", "data_analyst", "order_assistant"], - updated: [], - deleted: [], - }, - ); + firstCall401ThenSuccess("PUT", `/api/apps/${APP_ID}/agent-configs`, { + created: ["customer_support", "data_analyst", "order_assistant"], + updated: [], + deleted: [], + }); const result = await t.run("agents", "push"); @@ -64,15 +53,11 @@ describe("token refresh on 401", () => { it("retries PUT with json body after 401 token refresh (entities)", async () => { await t.givenLoggedInWithProject(fixture("with-entities")); mockTokenRefresh(); - firstCall401ThenSuccess( - "put", - `${BASE_URL}/api/apps/${APP_ID}/entity-schemas`, - { - created: ["tasks"], - updated: [], - deleted: [], - }, - ); + firstCall401ThenSuccess("PUT", `/api/apps/${APP_ID}/entity-schemas`, { + created: ["tasks"], + updated: [], + deleted: [], + }); const result = await t.run("entities", "push"); @@ -83,19 +68,15 @@ describe("token refresh on 401", () => { it("fails with actual error when token refresh also fails", async () => { await t.givenLoggedInWithProject(fixture("with-agents")); - // Token refresh returns 401 too (refresh token is also expired) - mswServer.use( - http.post(`${BASE_URL}/oauth/token`, () => - HttpResponse.json({ error: "invalid_grant" }, { status: 401 }), - ), - ); + // Token refresh returns 401 (refresh token is also expired) + t.api.mockRoute("POST", "/oauth/token", (_req, res) => { + res.status(401).json({ error: "invalid_grant" }); + }); // Agent push always returns 401 - mswServer.use( - http.put(`${BASE_URL}/api/apps/${APP_ID}/agent-configs`, () => - HttpResponse.json({ error: "Unauthorized" }, { status: 401 }), - ), - ); + t.api.mockRoute("PUT", `/api/apps/${APP_ID}/agent-configs`, (_req, res) => { + res.status(401).json({ error: "Unauthorized" }); + }); const result = await t.run("agents", "push"); diff --git a/packages/cli/tests/cli/testkit/BinaryAPIServer.ts b/packages/cli/tests/cli/testkit/BinaryAPIServer.ts index 38d437c4..6934b1b6 100644 --- a/packages/cli/tests/cli/testkit/BinaryAPIServer.ts +++ b/packages/cli/tests/cli/testkit/BinaryAPIServer.ts @@ -163,10 +163,6 @@ export class BinaryAPIServer { return `http://localhost:${this._port}`; } - get port(): number { - return this._port; - } - // ─── LIFECYCLE ─────────────────────────────────────────── async start(): Promise { @@ -388,17 +384,21 @@ export class BinaryAPIServer { ); } - // ─── ERROR RESPONSES ───────────────────────────────────── - - mockError( - method: "get" | "post" | "put" | "delete", + /** + * Register a custom Express handler for advanced scenarios (e.g. stateful + * responses that change behaviour across retries). + */ + mockRoute( + method: Method, path: string, - error: ErrorResponse, + handler: (req: express.Request, res: express.Response) => void, ): this { - const fullPath = path.startsWith("/") ? path : `/${path}`; - return this.addErrorRoute(method.toUpperCase() as Method, fullPath, error); + this.pendingRoutes.push({ method, path, handler }); + return this; } + // ─── ERROR RESPONSES ───────────────────────────────────── + mockEntitiesPushError(error: ErrorResponse): this { return this.addErrorRoute( "PUT", diff --git a/packages/cli/tests/cli/testkit/CLITestkit.ts b/packages/cli/tests/cli/testkit/CLITestkit.ts index 742f97b0..4087d033 100644 --- a/packages/cli/tests/cli/testkit/CLITestkit.ts +++ b/packages/cli/tests/cli/testkit/CLITestkit.ts @@ -1,38 +1,31 @@ -import { cpSync, existsSync, readFileSync } from "node:fs"; import { access, cp, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import type { Command } from "commander"; import { execa } from "execa"; import { dir } from "tmp-promise"; -import { vi } from "vitest"; -import { Base44APIMock } from "./Base44APIMock.js"; import { BinaryAPIServer } from "./BinaryAPIServer.js"; import type { CLIResult } from "./CLIResultMatcher.js"; import { CLIResultMatcher } from "./CLIResultMatcher.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const DIST_INDEX_PATH = join(__dirname, "../../../dist/cli/index.js"); -const DIST_ASSETS_DIR = join(__dirname, "../../../dist/assets"); -const BIN_RUN_PATH = join(__dirname, "../../../bin/run.js"); -/** When true, tests spawn the real binary instead of importing in-process */ -const BINARY_TEST_MODE = process.env.BINARY_TEST_MODE === "1"; - -/** Type for CLIContext */ -interface CLIContext { - errorReporter: { - setContext: (context: Record) => void; - getErrorContext: () => { sessionId?: string; appId?: string }; - }; - isNonInteractive: boolean; +/** Resolve the platform-specific compiled binary path */ +function getBinaryPath(): string { + const platform = + process.platform === "win32" + ? "windows" + : process.platform === "darwin" + ? "darwin" + : "linux"; + const arch = process.arch === "arm64" ? "arm64" : "x64"; + const ext = process.platform === "win32" ? ".exe" : ""; + return join( + __dirname, + `../../../dist/binaries/base44-${platform}-${arch}${ext}`, + ); } -/** Type for the bundled program module */ -interface ProgramModule { - createProgram: (context: CLIContext) => Command; - CLIExitError: new (code: number) => Error & { code: number }; -} +const BINARY_PATH = getBinaryPath(); /** Test overrides that get serialized to BASE44_CLI_TEST_OVERRIDES */ interface TestOverrides { @@ -48,14 +41,13 @@ export class CLITestkit { // Default latestVersion to null to skip npm version check in tests private testOverrides: TestOverrides = { latestVersion: null }; - /** Typed API mock for Base44 endpoints (MSW in-process or Express HTTP) */ - readonly api: Base44APIMock | BinaryAPIServer; + /** Real HTTP server for Base44 API endpoints */ + readonly api: BinaryAPIServer; private constructor( tempDir: string, cleanupFn: () => Promise, - appId: string, - api: Base44APIMock | BinaryAPIServer, + api: BinaryAPIServer, ) { this.tempDir = tempDir; this.cleanupFn = cleanupFn; @@ -69,13 +61,9 @@ export class CLITestkit { /** Factory method - creates isolated test environment */ static async create(appId = "test-app-id"): Promise { const { path, cleanup } = await dir({ unsafeCleanup: true }); - const api = BINARY_TEST_MODE - ? new BinaryAPIServer(appId) - : new Base44APIMock(appId); - if (api instanceof BinaryAPIServer) { - await api.start(); - } - return new CLITestkit(path, cleanup, appId, api); + const api = new BinaryAPIServer(appId); + await api.start(); + return new CLITestkit(path, cleanup, api); } /** Get the temp directory path */ @@ -119,28 +107,20 @@ export class CLITestkit { // ─── WHEN METHODS ───────────────────────────────────────────── - /** Execute CLI command */ + /** Spawn the real compiled binary and execute the CLI command */ async run(...args: string[]): Promise { - if (this.api instanceof BinaryAPIServer) { - return this.runBinary(args); - } - return this.runInProcess(args); - } - - /** Spawn the real binary as a subprocess */ - private async runBinary(args: string[]): Promise { this.setupEnvOverrides(); const env: Record = { ...this.env, - BASE44_API_URL: (this.api as BinaryAPIServer).baseUrl, + BASE44_API_URL: this.api.baseUrl, PATH: process.env.PATH ?? "", }; // Register all pending mock routes before spawning the binary - (this.api as BinaryAPIServer).apply(); + this.api.apply(); - const result = await execa("node", [BIN_RUN_PATH, ...args], { + const result = await execa(BINARY_PATH, args, { cwd: this.projectDir ?? this.tempDir, env, reject: false, @@ -154,93 +134,8 @@ export class CLITestkit { }; } - /** Import and run CLI in-process (original behavior) */ - private async runInProcess(args: string[]): Promise { - // Setup mocks - this.setupCwdMock(); - this.setupEnvOverrides(); - - // Save original env values for cleanup - const originalEnv = this.captureEnvSnapshot(); - - // Set testkit environment variables - Object.assign(process.env, this.env); - - // Ensure assets are available at the expected location (simulates ensureNpmAssets) - this.ensureTestAssets(); - - // Setup output capture - const { stdout, stderr, stdoutSpy, stderrSpy } = this.setupOutputCapture(); - - // Setup process.exit mock - const { exitState, originalExit } = this.setupExitMock(); - - // Apply all API mocks before running - this.api.apply(); - - // Reset module state to ensure test isolation - vi.resetModules(); - - // Import CLI module fresh after reset - const { createProgram, CLIExitError } = (await import( - DIST_INDEX_PATH - )) as ProgramModule; - - // Create a mock context for tests (telemetry is disabled via env var anyway) - const mockContext: CLIContext = { - errorReporter: { - setContext: () => {}, - getErrorContext: () => ({ sessionId: "test-session" }), - }, - isNonInteractive: true, - }; - const program = createProgram(mockContext); - - const buildResult = (exitCode: number): CLIResult => ({ - stdout: stdout.join(""), - stderr: stderr.join(""), - exitCode, - }); - - try { - await program.parseAsync(["node", "base44", ...args]); - return buildResult(0); - } catch (e) { - // process.exit() was called - our mock throws after capturing the code - // This catches Commander's exits for --help, --version, unknown options - if (exitState.code !== null) { - return buildResult(exitState.code); - } - // CLI's clean exit mechanism (user cancellation, etc.) - if (e instanceof CLIExitError) { - return buildResult(e.code); - } - // Any other error = command failed with exit code 1 - // Capture error message in stderr for test assertions - const errorMessage = - e instanceof Error ? (e.stack ?? e.message) : String(e); - stderr.push(errorMessage); - return buildResult(1); - } finally { - // Restore process.exit - process.exit = originalExit; - // Restore environment variables - this.restoreEnvSnapshot(originalEnv); - // Restore mocks - stdoutSpy.mockRestore(); - stderrSpy.mockRestore(); - vi.restoreAllMocks(); - } - } - // ─── PRIVATE HELPERS ─────────────────────────────────────────── - private setupCwdMock(): void { - if (this.projectDir) { - vi.spyOn(process, "cwd").mockReturnValue(this.projectDir); - } - } - private setupEnvOverrides(): void { if (this.projectDir) { this.testOverrides.appConfig = { @@ -253,72 +148,6 @@ export class CLITestkit { } } - private captureEnvSnapshot(): Record { - const snapshot: Record = {}; - for (const key of Object.keys(this.env)) { - snapshot[key] = process.env[key]; - } - return snapshot; - } - - private restoreEnvSnapshot( - snapshot: Record, - ): void { - for (const key of Object.keys(snapshot)) { - if (snapshot[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = snapshot[key]; - } - } - } - - private setupOutputCapture() { - const stdout: string[] = []; - const stderr: string[] = []; - - const stdoutSpy = vi - .spyOn(process.stdout, "write") - .mockImplementation((chunk) => { - stdout.push(String(chunk)); - return true; - }); - const stderrSpy = vi - .spyOn(process.stderr, "write") - .mockImplementation((chunk) => { - stderr.push(String(chunk)); - return true; - }); - - return { stdout, stderr, stdoutSpy, stderrSpy }; - } - - private setupExitMock() { - const exitState = { code: null as number | null }; - const originalExit = process.exit; - process.exit = ((code?: number) => { - exitState.code = code ?? 0; - throw new Error(`process.exit called with ${code}`); - }) as typeof process.exit; - - return { exitState, originalExit }; - } - - /** - * Copy dist/assets/ to the test HOME's expected location. - * This simulates what ensureNpmAssets() does in production, since - * tests bypass runCLI() and call createProgram() directly. - */ - private ensureTestAssets(): void { - if (!existsSync(DIST_ASSETS_DIR)) return; - const pkgPath = join(__dirname, "../../../package.json"); - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const assetsTarget = join(this.tempDir, ".base44", "assets", pkg.version); - if (!existsSync(assetsTarget)) { - cpSync(DIST_ASSETS_DIR, assetsTarget, { recursive: true }); - } - } - // ─── THEN METHODS ───────────────────────────────────────────── /** Create assertion helper for CLI result */ @@ -365,9 +194,7 @@ export class CLITestkit { // ─── CLEANUP ────────────────────────────────────────────────── async cleanup(): Promise { - if (this.api instanceof BinaryAPIServer) { - await this.api.stop(); - } + await this.api.stop(); await this.cleanupFn(); } } diff --git a/packages/cli/tests/cli/testkit/index.ts b/packages/cli/tests/cli/testkit/index.ts index bcbd4bf5..ed2c9551 100644 --- a/packages/cli/tests/cli/testkit/index.ts +++ b/packages/cli/tests/cli/testkit/index.ts @@ -1,15 +1,10 @@ import { resolve } from "node:path"; -import { setupServer } from "msw/node"; -import { afterAll, afterEach, beforeAll, beforeEach } from "vitest"; +import { afterEach, beforeEach } from "vitest"; import type { CLIResult, CLIResultMatcher } from "./CLIResultMatcher.js"; import { CLITestkit } from "./CLITestkit.js"; const FIXTURES_DIR = resolve(__dirname, "../../fixtures"); -const BINARY_TEST_MODE = process.env.BINARY_TEST_MODE === "1"; - -export const mswServer = setupServer(); - /** Resolve a fixture path by name */ export function fixture(name: string): string { return resolve(FIXTURES_DIR, name); @@ -94,32 +89,17 @@ export function setupCLITests(): TestContext { return currentKit; }; - beforeAll(() => { - if (!BINARY_TEST_MODE) { - mswServer.listen({ onUnhandledRequest: "bypass" }); - } - }); - beforeEach(async () => { currentKit = await CLITestkit.create(); }); afterEach(async () => { - if (!BINARY_TEST_MODE) { - mswServer.resetHandlers(); - } if (currentKit) { await currentKit.cleanup(); currentKit = null; } }); - afterAll(() => { - if (!BINARY_TEST_MODE) { - mswServer.close(); - } - }); - // Default user for givenLoggedInWithProject const defaultUser = { email: "test@example.com", name: "Test User" }; From 4c2b9d0c1b877fc7c9437825aafcdc0df3216a91 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:51:15 +0000 Subject: [PATCH 03/15] fix: remove Base44APIMock, fix Commander.js error display in binary mode - Delete Base44APIMock.ts (dead code, no tests use it); fixes typecheck error from mswServer import that no longer exists in index.ts - Remove msw from devDependencies since it was only used by Base44APIMock - Fix secrets set preAction hook to call command.error() instead of throwing, so Commander.js outputs the error to stderr when no args are provided - Fix logs --limit option parser to throw InvalidArgumentError (Commander type) instead of InvalidInputError, so Commander.js properly intercepts and displays it - Add tests/globalSetup.ts that auto-builds the binary if dist/binaries/ is missing - Add globalSetup to vitest.config.ts so CI never runs tests without a binary Fixes: typecheck error (mswServer), 2 failing tests (empty output on validation errors) Co-authored-by: Kfir Stri --- packages/cli/package.json | 1 - packages/cli/src/cli/commands/project/logs.ts | 4 +- packages/cli/src/cli/commands/secrets/set.ts | 6 +- .../cli/tests/cli/testkit/Base44APIMock.ts | 471 ------------------ packages/cli/tests/cli/testkit/index.ts | 1 - packages/cli/tests/globalSetup.ts | 32 ++ packages/cli/vitest.config.ts | 1 + 7 files changed, 37 insertions(+), 479 deletions(-) delete mode 100644 packages/cli/tests/cli/testkit/Base44APIMock.ts create mode 100644 packages/cli/tests/globalSetup.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index cb695775..66eac6a2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -67,7 +67,6 @@ "json5": "^2.2.3", "ky": "^1.14.2", "lodash": "^4.17.23", - "msw": "^2.12.10", "multer": "^2.0.0", "nanoid": "^5.1.6", "open": "^11.0.0", diff --git a/packages/cli/src/cli/commands/project/logs.ts b/packages/cli/src/cli/commands/project/logs.ts index e7e468a5..678751d8 100644 --- a/packages/cli/src/cli/commands/project/logs.ts +++ b/packages/cli/src/cli/commands/project/logs.ts @@ -1,4 +1,4 @@ -import { Command, Option } from "commander"; +import { Command, InvalidArgumentError, Option } from "commander"; import type { CLIContext } from "@/cli/types.js"; import { runCommand } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; @@ -222,7 +222,7 @@ export function getLogsCommand(context: CLIContext): Command { (v) => { const n = Number.parseInt(v, 10); if (Number.isNaN(n) || n < 1 || n > 1000) { - throw new InvalidInputError( + throw new InvalidArgumentError( `Invalid limit: "${v}". Must be a number between 1 and 1000.`, ); } diff --git a/packages/cli/src/cli/commands/secrets/set.ts b/packages/cli/src/cli/commands/secrets/set.ts index 5e860376..dc68f962 100644 --- a/packages/cli/src/cli/commands/secrets/set.ts +++ b/packages/cli/src/cli/commands/secrets/set.ts @@ -41,15 +41,13 @@ function validateInput(command: Command): void { const hasEnvFile = Boolean(envFile); if (!hasEntries && !hasEnvFile) { - throw new InvalidInputError( + command.error( "Provide KEY=VALUE pairs or use --env-file. Example: base44 secrets set KEY1=VALUE1 KEY2=VALUE2", ); } if (hasEntries && hasEnvFile) { - throw new InvalidInputError( - "Provide KEY=VALUE pairs or --env-file, but not both.", - ); + command.error("Provide KEY=VALUE pairs or --env-file, but not both."); } } diff --git a/packages/cli/tests/cli/testkit/Base44APIMock.ts b/packages/cli/tests/cli/testkit/Base44APIMock.ts deleted file mode 100644 index 9c68f21b..00000000 --- a/packages/cli/tests/cli/testkit/Base44APIMock.ts +++ /dev/null @@ -1,471 +0,0 @@ -import type { RequestHandler } from "msw"; -import { HttpResponse, http } from "msw"; -import { mswServer } from "./index.js"; - -const BASE_URL = "https://app.base44.com"; - -// ─── RESPONSE TYPES ────────────────────────────────────────── - -interface DeviceCodeResponse { - device_code: string; - user_code: string; - verification_uri: string; - expires_in: number; - interval: number; -} - -interface TokenResponse { - access_token: string; - refresh_token: string; - expires_in: number; - token_type: string; -} - -interface UserInfoResponse { - email: string; - name?: string; -} - -interface EntitiesPushResponse { - created: string[]; - updated: string[]; - deleted: string[]; -} - -interface FunctionsPushResponse { - deployed: string[]; - deleted: string[]; - errors: Array<{ name: string; message: string }> | null; -} - -interface SiteDeployResponse { - app_url: string; -} - -interface SiteUrlResponse { - url: string; -} - -interface AgentsPushResponse { - created: string[]; - updated: string[]; - deleted: string[]; -} - -interface AgentsFetchResponse { - items: Array<{ name: string; [key: string]: unknown }>; - total: number; -} - -interface FunctionLogEntry { - time: string; - level: "info" | "warning" | "error" | "debug"; - message: string; -} - -type FunctionLogsResponse = FunctionLogEntry[]; - -type SecretsListResponse = Record; - -interface SecretsSetResponse { - success: boolean; -} - -interface SecretsDeleteResponse { - success: boolean; -} - -interface ConnectorsListResponse { - integrations: Array<{ - integration_type: string; - status: string; - scopes: string[]; - user_email?: string; - }>; -} - -interface ConnectorSetResponse { - redirect_url: string | null; - connection_id: string | null; - already_authorized: boolean; - error?: "different_user"; - error_message?: string; - other_user_email?: string; -} - -interface ConnectorRemoveResponse { - status: "removed"; - integration_type: string; -} - -interface CreateAppResponse { - id: string; - name: string; -} - -interface ListProjectsResponse { - id: string; - name: string; - user_description?: string | null; - is_managed_source_code?: boolean; -} - -interface ErrorResponse { - status: number; - body?: unknown; -} - -// ─── MOCK CLASS ────────────────────────────────────────────── - -/** - * Typed API mock for Base44 endpoints. - * - * Method naming convention: - * - `mock()` - Mock successful response - * - `mockError()` - Mock error response - * - * @example - * ```typescript - * // Success responses - * api.mockEntitiesPush({ created: ["User"], updated: [], deleted: [] }); - * api.mockAgentsFetch({ items: [{ name: "support" }], total: 1 }); - * - * // Error responses - * api.mockEntitiesPushError({ status: 500, body: { error: "Server error" } }); - * ``` - */ -export class Base44APIMock { - private handlers: RequestHandler[] = []; - - constructor(readonly appId: string) {} - - // ─── AUTH ENDPOINTS ──────────────────────────────────────── - - /** Mock POST /oauth/device/code - Start device authorization flow */ - mockDeviceCode(response: DeviceCodeResponse): this { - this.handlers.push( - http.post(`${BASE_URL}/oauth/device/code`, () => - HttpResponse.json(response), - ), - ); - return this; - } - - /** Mock POST /oauth/token - Exchange code for tokens or refresh */ - mockToken(response: TokenResponse): this { - this.handlers.push( - http.post(`${BASE_URL}/oauth/token`, () => HttpResponse.json(response)), - ); - return this; - } - - /** Mock GET /oauth/userinfo - Get authenticated user info */ - mockUserInfo(response: UserInfoResponse): this { - this.handlers.push( - http.get(`${BASE_URL}/oauth/userinfo`, () => HttpResponse.json(response)), - ); - return this; - } - - // ─── APP-SCOPED ENDPOINTS ────────────────────────────────── - - /** Mock PUT /api/apps/{appId}/entity-schemas - Push entities */ - mockEntitiesPush(response: EntitiesPushResponse): this { - this.handlers.push( - http.put(`${BASE_URL}/api/apps/${this.appId}/entity-schemas`, () => - HttpResponse.json(response), - ), - ); - return this; - } - - /** Mock PUT /api/apps/{appId}/backend-functions - Push functions */ - mockFunctionsPush(response: FunctionsPushResponse): this { - this.handlers.push( - http.put(`${BASE_URL}/api/apps/${this.appId}/backend-functions`, () => - HttpResponse.json(response), - ), - ); - return this; - } - - /** Mock POST /api/apps/{appId}/deploy-dist - Deploy site */ - mockSiteDeploy(response: SiteDeployResponse): this { - this.handlers.push( - http.post(`${BASE_URL}/api/apps/${this.appId}/deploy-dist`, () => - HttpResponse.json(response), - ), - ); - return this; - } - - /** Mock GET /api/apps/platform/{appId}/published-url - Get site URL */ - mockSiteUrl(response: SiteUrlResponse): this { - this.handlers.push( - http.get( - `${BASE_URL}/api/apps/platform/${this.appId}/published-url`, - () => HttpResponse.json(response), - ), - ); - return this; - } - - /** Mock PUT /api/apps/{appId}/agent-configs - Push agents */ - mockAgentsPush(response: AgentsPushResponse): this { - this.handlers.push( - http.put(`${BASE_URL}/api/apps/${this.appId}/agent-configs`, () => - HttpResponse.json(response), - ), - ); - return this; - } - - /** Mock GET /api/apps/{appId}/agent-configs - Fetch agents */ - mockAgentsFetch(response: AgentsFetchResponse): this { - this.handlers.push( - http.get(`${BASE_URL}/api/apps/${this.appId}/agent-configs`, () => - HttpResponse.json(response), - ), - ); - return this; - } - - // ─── CONNECTOR ENDPOINTS ────────────────────────────────── - - /** Mock GET /api/apps/{appId}/external-auth/list - List connectors */ - mockConnectorsList(response: ConnectorsListResponse): this { - this.handlers.push( - http.get(`${BASE_URL}/api/apps/${this.appId}/external-auth/list`, () => - HttpResponse.json(response), - ), - ); - return this; - } - - /** Mock PUT /api/apps/{appId}/external-auth/integrations/{type} - Set connector */ - mockConnectorSet(response: ConnectorSetResponse): this { - this.handlers.push( - http.put( - `${BASE_URL}/api/apps/${this.appId}/external-auth/integrations/:type`, - () => - HttpResponse.json({ - error: null, - error_message: null, - other_user_email: null, - ...response, - }), - ), - ); - return this; - } - - /** Mock DELETE /api/apps/{appId}/external-auth/integrations/{type}/remove */ - mockConnectorRemove(response: ConnectorRemoveResponse): this { - this.handlers.push( - http.delete( - `${BASE_URL}/api/apps/${this.appId}/external-auth/integrations/:type/remove`, - () => HttpResponse.json(response), - ), - ); - return this; - } - - /** Mock GET /api/apps/{appId}/functions-mgmt/{functionName}/logs - Fetch function logs */ - mockFunctionLogs(functionName: string, response: FunctionLogsResponse): this { - this.handlers.push( - http.get( - `${BASE_URL}/api/apps/${this.appId}/functions-mgmt/${functionName}/logs`, - () => HttpResponse.json(response), - ), - ); - return this; - } - - // ─── SECRETS ENDPOINTS ────────────────────────────────────── - - /** Mock GET /api/apps/{appId}/secrets - List secrets */ - mockSecretsList(response: SecretsListResponse): this { - this.handlers.push( - http.get(`${BASE_URL}/api/apps/${this.appId}/secrets`, () => - HttpResponse.json(response), - ), - ); - return this; - } - - /** Mock POST /api/apps/{appId}/secrets - Set secrets */ - mockSecretsSet(response: SecretsSetResponse): this { - this.handlers.push( - http.post(`${BASE_URL}/api/apps/${this.appId}/secrets`, () => - HttpResponse.json(response), - ), - ); - return this; - } - - /** Mock DELETE /api/apps/{appId}/secrets - Delete secret */ - mockSecretsDelete(response: SecretsDeleteResponse): this { - this.handlers.push( - http.delete(`${BASE_URL}/api/apps/${this.appId}/secrets`, () => - HttpResponse.json(response), - ), - ); - return this; - } - - // ─── GENERAL ENDPOINTS ───────────────────────────────────── - - /** Mock POST /api/apps - Create new app */ - mockCreateApp(response: CreateAppResponse): this { - this.handlers.push( - http.post(`${BASE_URL}/api/apps`, () => HttpResponse.json(response)), - ); - return this; - } - - /** Mock GET /api/apps - List projects */ - mockListProjects(response: ListProjectsResponse[]): this { - this.handlers.push( - http.get(`${BASE_URL}/api/apps`, () => HttpResponse.json(response)), - ); - return this; - } - - /** Mock GET /api/apps/{appId}/eject - Download project as tar */ - mockProjectEject(tarContent: Uint8Array = new Uint8Array()): this { - this.handlers.push( - http.get( - `${BASE_URL}/api/apps/${this.appId}/eject`, - () => - new HttpResponse(tarContent, { - headers: { "Content-Type": "application/gzip" }, - }), - ), - ); - return this; - } - - // ─── ERROR RESPONSES ──────────────────────────────────────── - - /** Mock any endpoint to return an error */ - mockError( - method: "get" | "post" | "put" | "delete", - path: string, - error: ErrorResponse, - ): this { - const url = path.startsWith("/") - ? `${BASE_URL}${path}` - : `${BASE_URL}/${path}`; - this.handlers.push( - http[method](url, () => - HttpResponse.json(error.body ?? { error: "Error" }, { - status: error.status, - }), - ), - ); - return this; - } - - /** Mock entities push to return an error */ - mockEntitiesPushError(error: ErrorResponse): this { - return this.mockError( - "put", - `/api/apps/${this.appId}/entity-schemas`, - error, - ); - } - - /** Mock functions push to return an error */ - mockFunctionsPushError(error: ErrorResponse): this { - return this.mockError( - "put", - `/api/apps/${this.appId}/backend-functions`, - error, - ); - } - - /** Mock site deploy to return an error */ - mockSiteDeployError(error: ErrorResponse): this { - return this.mockError("post", `/api/apps/${this.appId}/deploy-dist`, error); - } - - /** Mock site URL to return an error */ - mockSiteUrlError(error: ErrorResponse): this { - return this.mockError( - "get", - `/api/apps/platform/${this.appId}/published-url`, - error, - ); - } - - /** Mock agents push to return an error */ - mockAgentsPushError(error: ErrorResponse): this { - return this.mockError( - "put", - `/api/apps/${this.appId}/agent-configs`, - error, - ); - } - - /** Mock agents fetch to return an error */ - mockAgentsFetchError(error: ErrorResponse): this { - return this.mockError( - "get", - `/api/apps/${this.appId}/agent-configs`, - error, - ); - } - - /** Mock function logs to return an error */ - mockFunctionLogsError(functionName: string, error: ErrorResponse): this { - return this.mockError( - "get", - `/api/apps/${this.appId}/functions-mgmt/${functionName}/logs`, - error, - ); - } - - /** Mock token endpoint to return an error (for auth failure testing) */ - - /** Mock secrets list to return an error */ - mockSecretsListError(error: ErrorResponse): this { - return this.mockError("get", `/api/apps/${this.appId}/secrets`, error); - } - - /** Mock secrets set to return an error */ - mockSecretsSetError(error: ErrorResponse): this { - return this.mockError("post", `/api/apps/${this.appId}/secrets`, error); - } - - /** Mock secrets delete to return an error */ - mockSecretsDeleteError(error: ErrorResponse): this { - return this.mockError("delete", `/api/apps/${this.appId}/secrets`, error); - } - - /** Mock connectors list to return an error */ - mockConnectorsListError(error: ErrorResponse): this { - return this.mockError( - "get", - `/api/apps/${this.appId}/external-auth/list`, - error, - ); - } - - /** Mock connector set to return an error */ - mockConnectorSetError(error: ErrorResponse): this { - return this.mockError( - "put", - `/api/apps/${this.appId}/external-auth/integrations/:type`, - error, - ); - } - - // ─── INTERNAL ────────────────────────────────────────────── - - /** Apply all registered handlers to MSW (called by CLITestkit.run()) */ - apply(): void { - if (this.handlers.length > 0) { - mswServer.use(...this.handlers); - } - } -} diff --git a/packages/cli/tests/cli/testkit/index.ts b/packages/cli/tests/cli/testkit/index.ts index ed2c9551..8bbfa185 100644 --- a/packages/cli/tests/cli/testkit/index.ts +++ b/packages/cli/tests/cli/testkit/index.ts @@ -134,7 +134,6 @@ export function setupCLITests(): TestContext { }; } -export { Base44APIMock } from "./Base44APIMock.js"; export { BinaryAPIServer } from "./BinaryAPIServer.js"; export type { CLIResult } from "./CLIResultMatcher.js"; export { CLIResultMatcher } from "./CLIResultMatcher.js"; diff --git a/packages/cli/tests/globalSetup.ts b/packages/cli/tests/globalSetup.ts new file mode 100644 index 00000000..ea270912 --- /dev/null +++ b/packages/cli/tests/globalSetup.ts @@ -0,0 +1,32 @@ +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PKG_DIR = join(__dirname, ".."); + +function getBinaryPath(): string { + const platform = + process.platform === "win32" + ? "windows" + : process.platform === "darwin" + ? "darwin" + : "linux"; + const arch = process.arch === "arm64" ? "arm64" : "x64"; + const ext = process.platform === "win32" ? ".exe" : ""; + return join(PKG_DIR, `dist/binaries/base44-${platform}-${arch}${ext}`); +} + +export default function setup(): void { + const binaryPath = getBinaryPath(); + if (!existsSync(binaryPath)) { + console.log( + "\n[globalSetup] Binary not found — running build + build:binaries...\n", + ); + execSync("bun run build && bun run build:binaries", { + cwd: PKG_DIR, + stdio: "inherit", + }); + } +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 46a91b01..a541a2ae 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ environment: "node", globals: true, include: ["tests/**/*.spec.ts"], + globalSetup: ["./tests/globalSetup.ts"], testTimeout: 30000, mockReset: true, silent: true, // Suppress stdout/stderr from tests (CLI output is very noisy) From 96fef46fdf3d69173e7e1f8760f0b89594b4b655 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Mon, 9 Mar 2026 17:59:41 +0200 Subject: [PATCH 04/15] update bun lock --- bun.lock | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 5767ab09..d36848bc 100644 --- a/bun.lock +++ b/bun.lock @@ -46,7 +46,6 @@ "json5": "^2.2.3", "ky": "^1.14.2", "lodash": "^4.17.23", - "msw": "^2.12.10", "multer": "^2.0.0", "nanoid": "^5.1.6", "open": "^11.0.0", @@ -685,7 +684,7 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -901,6 +900,8 @@ "msw/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "msw/path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "multer/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -909,8 +910,6 @@ "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], - "socket.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], From 984c31ec932d3e3a09c6c06142d619236ce4619c Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Mon, 9 Mar 2026 18:02:59 +0200 Subject: [PATCH 05/15] update bun lock --- bun.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/bun.lock b/bun.lock index 6e75ab51..895a8a94 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "base44-cli", From 31a1123b2cbf6fa21ea3daa0340663facacd57bf Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:10:55 +0000 Subject: [PATCH 06/15] revert: use InvalidInputError in logs --limit option parser Reverts the change from InvalidArgumentError (Commander.js) back to InvalidInputError (project's error class) in the --limit option parser. Co-authored-by: Kfir Stri --- packages/cli/src/cli/commands/project/logs.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/commands/project/logs.ts b/packages/cli/src/cli/commands/project/logs.ts index 678751d8..e7e468a5 100644 --- a/packages/cli/src/cli/commands/project/logs.ts +++ b/packages/cli/src/cli/commands/project/logs.ts @@ -1,4 +1,4 @@ -import { Command, InvalidArgumentError, Option } from "commander"; +import { Command, Option } from "commander"; import type { CLIContext } from "@/cli/types.js"; import { runCommand } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; @@ -222,7 +222,7 @@ export function getLogsCommand(context: CLIContext): Command { (v) => { const n = Number.parseInt(v, 10); if (Number.isNaN(n) || n < 1 || n > 1000) { - throw new InvalidArgumentError( + throw new InvalidInputError( `Invalid limit: "${v}". Must be a number between 1 and 1000.`, ); } From 7160067de399ec418a5d7e5999cdde41531c045e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:11:07 +0000 Subject: [PATCH 07/15] revert: restore InvalidInputError in secrets/set.ts validateInput Reverts command.error() back to throwing InvalidInputError, restoring original behavior for input validation in the preAction hook. Co-authored-by: Kfir Stri --- packages/cli/src/cli/commands/secrets/set.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/commands/secrets/set.ts b/packages/cli/src/cli/commands/secrets/set.ts index dc68f962..5e860376 100644 --- a/packages/cli/src/cli/commands/secrets/set.ts +++ b/packages/cli/src/cli/commands/secrets/set.ts @@ -41,13 +41,15 @@ function validateInput(command: Command): void { const hasEnvFile = Boolean(envFile); if (!hasEntries && !hasEnvFile) { - command.error( + throw new InvalidInputError( "Provide KEY=VALUE pairs or use --env-file. Example: base44 secrets set KEY1=VALUE1 KEY2=VALUE2", ); } if (hasEntries && hasEnvFile) { - command.error("Provide KEY=VALUE pairs or --env-file, but not both."); + throw new InvalidInputError( + "Provide KEY=VALUE pairs or --env-file, but not both.", + ); } } From b38aca45a213a2072dd6318119c34bab34f17e05 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:12:51 +0000 Subject: [PATCH 08/15] remove globalSetup.ts and vitest globalSetup config Users are responsible for running bun run build and bun run build:binaries before tests. Co-authored-by: Kfir Stri --- packages/cli/tests/globalSetup.ts | 32 ------------------------------- packages/cli/vitest.config.ts | 3 +-- 2 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 packages/cli/tests/globalSetup.ts diff --git a/packages/cli/tests/globalSetup.ts b/packages/cli/tests/globalSetup.ts deleted file mode 100644 index ea270912..00000000 --- a/packages/cli/tests/globalSetup.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { execSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const PKG_DIR = join(__dirname, ".."); - -function getBinaryPath(): string { - const platform = - process.platform === "win32" - ? "windows" - : process.platform === "darwin" - ? "darwin" - : "linux"; - const arch = process.arch === "arm64" ? "arm64" : "x64"; - const ext = process.platform === "win32" ? ".exe" : ""; - return join(PKG_DIR, `dist/binaries/base44-${platform}-${arch}${ext}`); -} - -export default function setup(): void { - const binaryPath = getBinaryPath(); - if (!existsSync(binaryPath)) { - console.log( - "\n[globalSetup] Binary not found — running build + build:binaries...\n", - ); - execSync("bun run build && bun run build:binaries", { - cwd: PKG_DIR, - stdio: "inherit", - }); - } -} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index a541a2ae..d1835967 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -6,8 +6,7 @@ export default defineConfig({ environment: "node", globals: true, include: ["tests/**/*.spec.ts"], - globalSetup: ["./tests/globalSetup.ts"], - testTimeout: 30000, +testTimeout: 30000, mockReset: true, silent: true, // Suppress stdout/stderr from tests (CLI output is very noisy) }, From b50f8276bddad022beb5653ed92a24cf88165e5e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:15:42 +0000 Subject: [PATCH 09/15] refactor: inline addBinaryRoute into mockProjectEject, remove unused private helper Co-authored-by: Kfir Stri --- .../cli/tests/cli/testkit/BinaryAPIServer.ts | 32 ++++++------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/packages/cli/tests/cli/testkit/BinaryAPIServer.ts b/packages/cli/tests/cli/testkit/BinaryAPIServer.ts index 1bb14acc..eb4d55e4 100644 --- a/packages/cli/tests/cli/testkit/BinaryAPIServer.ts +++ b/packages/cli/tests/cli/testkit/BinaryAPIServer.ts @@ -235,23 +235,6 @@ export class BinaryAPIServer { return this; } - private addBinaryRoute( - method: Method, - path: string, - data: Uint8Array, - contentType: string, - ): this { - this.pendingRoutes.push({ - method, - path, - handler: (_req, res) => { - res.setHeader("Content-Type", contentType); - res.status(200).send(Buffer.from(data)); - }, - }); - return this; - } - private addErrorRoute( method: Method, path: string, @@ -403,12 +386,15 @@ export class BinaryAPIServer { } mockProjectEject(tarContent: Uint8Array = new Uint8Array()): this { - return this.addBinaryRoute( - "GET", - `/api/apps/${this.appId}/eject`, - tarContent, - "application/gzip", - ); + this.pendingRoutes.push({ + method: "GET", + path: `/api/apps/${this.appId}/eject`, + handler: (_req, res) => { + res.setHeader("Content-Type", "application/gzip"); + res.status(200).send(Buffer.from(tarContent)); + }, + }); + return this; } /** From bf747f5ba9412aad21e86badb78f56f966a1da0f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:16:09 +0000 Subject: [PATCH 10/15] rename BinaryAPIServer to TestAPIServer Co-authored-by: Kfir Stri --- packages/cli/tests/cli/testkit/CLITestkit.ts | 8 ++++---- .../cli/testkit/{BinaryAPIServer.ts => TestAPIServer.ts} | 4 ++-- packages/cli/tests/cli/testkit/index.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) rename packages/cli/tests/cli/testkit/{BinaryAPIServer.ts => TestAPIServer.ts} (99%) diff --git a/packages/cli/tests/cli/testkit/CLITestkit.ts b/packages/cli/tests/cli/testkit/CLITestkit.ts index 4087d033..130cc49b 100644 --- a/packages/cli/tests/cli/testkit/CLITestkit.ts +++ b/packages/cli/tests/cli/testkit/CLITestkit.ts @@ -3,7 +3,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { execa } from "execa"; import { dir } from "tmp-promise"; -import { BinaryAPIServer } from "./BinaryAPIServer.js"; +import { TestAPIServer } from "./TestAPIServer.js"; import type { CLIResult } from "./CLIResultMatcher.js"; import { CLIResultMatcher } from "./CLIResultMatcher.js"; @@ -42,12 +42,12 @@ export class CLITestkit { private testOverrides: TestOverrides = { latestVersion: null }; /** Real HTTP server for Base44 API endpoints */ - readonly api: BinaryAPIServer; + readonly api: TestAPIServer; private constructor( tempDir: string, cleanupFn: () => Promise, - api: BinaryAPIServer, + api: TestAPIServer, ) { this.tempDir = tempDir; this.cleanupFn = cleanupFn; @@ -61,7 +61,7 @@ export class CLITestkit { /** Factory method - creates isolated test environment */ static async create(appId = "test-app-id"): Promise { const { path, cleanup } = await dir({ unsafeCleanup: true }); - const api = new BinaryAPIServer(appId); + const api = new TestAPIServer(appId); await api.start(); return new CLITestkit(path, cleanup, api); } diff --git a/packages/cli/tests/cli/testkit/BinaryAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts similarity index 99% rename from packages/cli/tests/cli/testkit/BinaryAPIServer.ts rename to packages/cli/tests/cli/testkit/TestAPIServer.ts index eb4d55e4..ca3c31e9 100644 --- a/packages/cli/tests/cli/testkit/BinaryAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -153,7 +153,7 @@ interface RouteEntry { * * @example * ```typescript - * const server = new BinaryAPIServer("my-app-id"); + * const server = new TestAPIServer("my-app-id"); * await server.start(); * * server.mockEntitiesPush({ created: ["User"], updated: [], deleted: [] }); @@ -164,7 +164,7 @@ interface RouteEntry { * await server.stop(); * ``` */ -export class BinaryAPIServer { +export class TestAPIServer { private pendingRoutes: RouteEntry[] = []; private app: express.Application; private server: Server | null = null; diff --git a/packages/cli/tests/cli/testkit/index.ts b/packages/cli/tests/cli/testkit/index.ts index 8bbfa185..171eb326 100644 --- a/packages/cli/tests/cli/testkit/index.ts +++ b/packages/cli/tests/cli/testkit/index.ts @@ -134,7 +134,7 @@ export function setupCLITests(): TestContext { }; } -export { BinaryAPIServer } from "./BinaryAPIServer.js"; +export { TestAPIServer } from "./TestAPIServer.js"; export type { CLIResult } from "./CLIResultMatcher.js"; export { CLIResultMatcher } from "./CLIResultMatcher.js"; // Re-export types and classes that tests might need From 4cdb61c1849cbcd9d91fbc059219f960593b09e2 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:16:23 +0000 Subject: [PATCH 11/15] docs: remove MSW reference from BinaryAPIServer class comment Co-authored-by: Kfir Stri --- packages/cli/tests/cli/testkit/TestAPIServer.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index ca3c31e9..6d98ec65 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -146,10 +146,11 @@ interface RouteEntry { // ─── SERVER CLASS ──────────────────────────────────────────── /** - * Real HTTP server that replaces MSW for binary integration tests. + * Lightweight Express HTTP server used in integration tests to simulate the Base44 API. * - * Exposes the same `mock*` interface as `Base44APIMock` so test files - * are unchanged when switching between in-process and binary modes. + * Each test registers expected responses via `mock*` helpers, calls `apply()` to + * activate the routes, then spawns the CLI binary with `BASE44_API_URL` pointed at + * this server so all HTTP traffic is intercepted locally. * * @example * ```typescript From cef6345be5bfeff7a2ab3ccc5ba9d28566b192e8 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Mon, 9 Mar 2026 18:40:41 +0200 Subject: [PATCH 12/15] small changes to validation --- packages/cli/src/cli/commands/project/logs.ts | 25 +++++++++---------- packages/cli/src/cli/commands/secrets/set.ts | 9 +++---- packages/cli/tests/cli/testkit/CLITestkit.ts | 2 +- packages/cli/tests/cli/testkit/index.ts | 2 +- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/cli/commands/project/logs.ts b/packages/cli/src/cli/commands/project/logs.ts index e7e468a5..94a18a61 100644 --- a/packages/cli/src/cli/commands/project/logs.ts +++ b/packages/cli/src/cli/commands/project/logs.ts @@ -161,7 +161,18 @@ async function getAllFunctionNames(): Promise { return functions.map((fn) => fn.name); } +function validateLimit(limit: string | undefined): void { + if (limit === undefined) return; + const n = Number.parseInt(limit, 10); + if (Number.isNaN(n) || n < 1 || n > 1000) { + throw new InvalidInputError( + `Invalid limit: "${limit}". Must be a number between 1 and 1000.`, + ); + } +} + async function logsAction(options: LogsOptions): Promise { + validateLimit(options.limit); const specifiedFunctions = parseFunctionNames(options.function); // Always read project functions so we can list them in error messages @@ -216,19 +227,7 @@ export function getLogsCommand(context: CLIContext): Command { .choices([...LogLevelSchema.options]) .hideHelp(), ) - .option( - "-n, --limit ", - "Results per page (1-1000, default: 50)", - (v) => { - const n = Number.parseInt(v, 10); - if (Number.isNaN(n) || n < 1 || n > 1000) { - throw new InvalidInputError( - `Invalid limit: "${v}". Must be a number between 1 and 1000.`, - ); - } - return v; - }, - ) + .option("-n, --limit ", "Results per page (1-1000, default: 50)") .addOption( new Option("--order ", "Sort order").choices(["asc", "desc"]), ) diff --git a/packages/cli/src/cli/commands/secrets/set.ts b/packages/cli/src/cli/commands/secrets/set.ts index 5e860376..04c44fc0 100644 --- a/packages/cli/src/cli/commands/secrets/set.ts +++ b/packages/cli/src/cli/commands/secrets/set.ts @@ -34,11 +34,9 @@ function parseEntries(entries: string[]): Record { return secrets; } -function validateInput(command: Command): void { - const entries = command.args; - const { envFile } = command.opts<{ envFile?: string }>(); +function validateInput(entries: string[], options: { envFile?: string }): void { const hasEntries = entries.length > 0; - const hasEnvFile = Boolean(envFile); + const hasEnvFile = Boolean(options.envFile); if (!hasEntries && !hasEnvFile) { throw new InvalidInputError( @@ -57,6 +55,8 @@ async function setSecretsAction( entries: string[], options: { envFile?: string }, ): Promise { + validateInput(entries, options); + let secrets: Record; if (options.envFile) { @@ -95,7 +95,6 @@ export function getSecretsSetCommand(context: CLIContext): Command { .description("Set one or more secrets (KEY=VALUE format)") .argument("[entries...]", "KEY=VALUE pairs (e.g. KEY1=VALUE1 KEY2=VALUE2)") .option("--env-file ", "Path to .env file") - .hook("preAction", validateInput) .action(async (entries: string[], options: { envFile?: string }) => { await runCommand( () => setSecretsAction(entries, options), diff --git a/packages/cli/tests/cli/testkit/CLITestkit.ts b/packages/cli/tests/cli/testkit/CLITestkit.ts index 130cc49b..639d1d8c 100644 --- a/packages/cli/tests/cli/testkit/CLITestkit.ts +++ b/packages/cli/tests/cli/testkit/CLITestkit.ts @@ -3,9 +3,9 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { execa } from "execa"; import { dir } from "tmp-promise"; -import { TestAPIServer } from "./TestAPIServer.js"; import type { CLIResult } from "./CLIResultMatcher.js"; import { CLIResultMatcher } from "./CLIResultMatcher.js"; +import { TestAPIServer } from "./TestAPIServer.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/packages/cli/tests/cli/testkit/index.ts b/packages/cli/tests/cli/testkit/index.ts index 171eb326..5ca73764 100644 --- a/packages/cli/tests/cli/testkit/index.ts +++ b/packages/cli/tests/cli/testkit/index.ts @@ -134,8 +134,8 @@ export function setupCLITests(): TestContext { }; } -export { TestAPIServer } from "./TestAPIServer.js"; export type { CLIResult } from "./CLIResultMatcher.js"; export { CLIResultMatcher } from "./CLIResultMatcher.js"; // Re-export types and classes that tests might need export { CLITestkit } from "./CLITestkit.js"; +export { TestAPIServer } from "./TestAPIServer.js"; From 93e5d0e0327f1cad812b141be67c222b9a5c2fe3 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Mon, 9 Mar 2026 18:43:23 +0200 Subject: [PATCH 13/15] fix Test action --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b04cea49..8c65be78 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,9 @@ jobs: - name: Build run: bun run build + - name: Build binaries + run: bun run build:binaries + - name: Run tests run: bun run test From bc644d66fb06727a182ecee8d38e25fb4151c180 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Tue, 10 Mar 2026 15:15:34 +0200 Subject: [PATCH 14/15] update docs --- docs/AGENTS.md | 17 ++++++------ docs/testing.md | 71 ++++++++++++++++++++++++++++++------------------- 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 1ea9eb7e..9c4ee788 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -40,14 +40,15 @@ Zero-dependency npm package. All runtime dependencies are bundled into `dist/ind ## Development Commands ```bash -bun install # Install dependencies -bun run build # Bundle to dist/index.js + copy templates -bun run typecheck # tsc --noEmit -bun run dev # Run bin/dev.ts (no build needed, Bun runs TS directly) -bun run start # Run bin/run.js (requires build first) -bun run test # Run tests with vitest (use `bun run test`, not `bun test`) -bun run lint # Biome - lint and format check -bun run lint:fix # Biome - auto-fix +bun install # Install dependencies +bun run build # Bundle to dist/index.js + copy templates +bun run build:binaries # Compile standalone binaries (required before tests) +bun run typecheck # tsc --noEmit +bun run dev # Run bin/dev.ts (no build needed, Bun runs TS directly) +bun run start # Run bin/run.js (requires build first) +bun run test # Run tests with vitest (use `bun run test`, not `bun test`) +bun run lint # Biome - lint and format check +bun run lint:fix # Biome - auto-fix ``` **Prerequisites**: Bun (`curl -fsSL https://bun.sh/install | bash`), Node.js >= 20.19.0 (for npm publishing). diff --git a/docs/testing.md b/docs/testing.md index f183b422..0be98f99 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,6 +1,6 @@ # Writing Tests -**Keywords:** test, vitest, testkit, setupCLITests, fixture, mock, Given/When/Then, BASE44_CLI_TEST_OVERRIDES, build before test, MSW +**Keywords:** test, vitest, testkit, setupCLITests, fixture, mock, Given/When/Then, BASE44_CLI_TEST_OVERRIDES, build before test, binary, TestAPIServer, Express ## Table of Contents @@ -8,34 +8,35 @@ - [Test Structure](#test-structure) - [Writing a Test](#writing-a-test) - [Testkit API](#testkit-api) (Given / When / Then / File Assertions / Utilities) -- [API Mocks](#api-mocks) (Entity / Function / Agent / Site / Connector / Auth / Project / Generic) +- [API Mocks](#api-mocks) (Entity / Function / Agent / Site / Connector / Auth / Project / Custom Routes) - [Test Overrides](#test-overrides-base44_cli_test_overrides) (Adding a New Override) - [Testing Rules](#testing-rules) --- -**Build before testing**: Tests import the bundled `dist/index.js`, so always run: +**Build before testing**: Tests spawn the compiled binary, so always build both the bundle and binaries: ```bash -bun run build && bun run test +bun run build && bun run build:binaries && bun run test ``` ## How Testing Works -Tests use **MSW (Mock Service Worker)** to intercept HTTP requests. The testkit wraps MSW and provides a typed API for mocking Base44 endpoints. Tests run the actual bundled CLI code (from `dist/`), not source files. +Tests spawn the **real compiled binary** as a child process and communicate via stdout/stderr/exit code. A lightweight **Express HTTP server** (`TestAPIServer`) runs locally to simulate the Base44 API — the binary is pointed at it via `BASE44_API_URL`. This means: -- **`vi.mock()` won't work** with path aliases like `@/some/path.js` (they're resolved in the bundle) -- Use the **`BASE44_CLI_TEST_OVERRIDES` env var** for mocking behavior instead (see below) -- Always `bun run build` before `bun run test` to ensure the bundle is fresh -- Tests always run with `isNonInteractive: true` (no TTY), so browser opens and animations are skipped +- Tests exercise the full CLI pipeline (argument parsing, error handling, output formatting) +- **`vi.mock()` won't work** — the binary is a standalone executable, not an in-process import +- Use the **`BASE44_CLI_TEST_OVERRIDES` env var** for injecting test behavior (see below) +- Always `bun run build && bun run build:binaries` before `bun run test` to ensure the binary is fresh +- Tests always run with `CI=true` (no TTY), so browser opens and animations are skipped ## Test Structure ``` tests/ ├── cli/ # CLI integration tests -│ ├── testkit/ # Test utilities (CLITestkit, Base44APIMock) +│ ├── testkit/ # Test utilities (CLITestkit, TestAPIServer) │ ├── .spec.ts # e.g., login.spec.ts, deploy.spec.ts │ └── _.spec.ts # e.g., entities_push.spec.ts ├── core/ # Core module unit tests @@ -91,7 +92,7 @@ describe(" command", () => { // Then t.expectResult(result).toFail(); - t.expectResult(result).toContainInStderr("Server error"); + t.expectResult(result).toContain("Server error"); }); }); ``` @@ -100,7 +101,7 @@ describe(" command", () => { ### Setup -`setupCLITests()` -- Call inside `describe()`, returns test context `t`. Handles MSW server lifecycle, temp directory creation/cleanup, and test isolation automatically. +`setupCLITests()` -- Call inside `describe()`, returns test context `t`. Handles `TestAPIServer` lifecycle, temp directory creation/cleanup, and test isolation automatically via `beforeEach`/`afterEach`. ### Given (Setup State) @@ -148,15 +149,10 @@ interface CLIResult { // Exit code assertions t.expectResult(result).toSucceed(); // exitCode === 0 t.expectResult(result).toFail(); // exitCode !== 0 -t.expectResult(result).toHaveExitCode(2); // Specific exit code // Output assertions (searches both stdout + stderr) t.expectResult(result).toContain("Success"); t.expectResult(result).toNotContain("Error"); - -// Targeted output assertions -t.expectResult(result).toContainInStdout("Created entity"); -t.expectResult(result).toContainInStderr("Server error"); ``` ### File Assertions @@ -181,7 +177,7 @@ t.getTempDir() // Get the temp directory path (isolated per test) ## API Mocks -The `t.api` object provides typed mocks for all Base44 API endpoints. Mock methods are chainable. +The `t.api` object (`TestAPIServer`) provides typed mocks for all Base44 API endpoints. Each test gets its own Express server on a random port. Mock methods are chainable. ### Entity Mocks @@ -228,10 +224,11 @@ t.api.mockConnectorSet({ connection_id: "conn-123", already_authorized: false, }); -t.api.mockConnectorOAuthStatus({ status: "ACTIVE" }); t.api.mockConnectorRemove({ status: "removed", integration_type: "googlecalendar" }); +t.api.mockAvailableIntegrationsList({ integrations: [...] }); t.api.mockConnectorsListError({ status: 500, body: { error: "Server error" } }); t.api.mockConnectorSetError({ status: 401, body: { error: "Unauthorized" } }); +t.api.mockAvailableIntegrationsListError({ status: 500, body: { error: "Server error" } }); ``` ### Auth Mocks @@ -251,8 +248,6 @@ t.api.mockToken({ token_type: "Bearer", }); t.api.mockUserInfo({ email: "test@example.com", name: "Test User" }); -t.api.mockTokenError({ status: 401, body: { error: "invalid_grant" } }); -t.api.mockUserInfoError({ status: 401, body: { error: "Unauthorized" } }); ``` ### Project Mocks @@ -266,14 +261,33 @@ t.api.mockListProjects([ t.api.mockProjectEject(tarContentAsUint8Array); ``` -### Generic Error Mock +### Secrets Mocks + +```typescript +t.api.mockSecretsList({ SECRET_KEY: "***" }); +t.api.mockSecretsSet({ success: true }); +t.api.mockSecretsDelete({ success: true }); +t.api.mockSecretsListError({ status: 500, body: { error: "Server error" } }); +t.api.mockSecretsSetError({ status: 500, body: { error: "Server error" } }); +t.api.mockSecretsDeleteError({ status: 500, body: { error: "Server error" } }); +``` + +### Function Logs Mocks + +```typescript +t.api.mockFunctionLogs("my-function", [ + { time: "2025-01-01T00:00:00Z", level: "info", message: "Hello" }, +]); +t.api.mockFunctionLogsError("my-function", { status: 500, body: { error: "Server error" } }); +``` + +### Custom Route Mock -For endpoints without a specific error helper: +For advanced scenarios (e.g. stateful responses across retries): ```typescript -t.api.mockError("get", "/api/apps/test-app-id/some-endpoint", { - status: 500, - body: { error: "Something went wrong" }, +t.api.mockRoute("PUT", `/api/apps/${appId}/entity-schemas`, (req, res) => { + res.status(200).json({ created: [], updated: [], deleted: [] }); }); ``` @@ -281,7 +295,7 @@ t.api.mockError("get", "/api/apps/test-app-id/some-endpoint", { ## Test Overrides (`BASE44_CLI_TEST_OVERRIDES`) -For behaviors that can't be mocked via MSW (like filesystem-based config loading), the CLI uses a centralized JSON override mechanism. +For behaviors that can't be mocked via the API server (like filesystem-based config loading), the CLI uses a centralized JSON override mechanism. **Current overrides:** - `appConfig` -- Mock app configuration (id, projectRoot). Set automatically by `givenProject()` @@ -325,9 +339,10 @@ function getTestOverride(): MyType | undefined { ## Testing Rules -1. **Build first** -- Always `bun run build` before `bun run test` +1. **Build first** -- Always `bun run build && bun run build:binaries` before `bun run test` 2. **Use fixtures** -- Don't create project structures in tests; use `tests/fixtures/` 3. **Fixtures need `.app.jsonc`** -- Add `base44/.app.jsonc` with `{ "id": "test-app-id" }` 4. **Interactive prompts can't be tested** -- Only test via non-interactive flags 5. **Use test overrides** -- Extend `BASE44_CLI_TEST_OVERRIDES` for new testable behaviors; don't create new env vars 6. **Mock snake_case, code camelCase** -- API mocks use snake_case keys matching the real API +7. **Errors inside `runCommand` are displayed** -- Validation that needs to show error messages to users must run inside `runCommand`'s callback, not in Commander `preAction` hooks or option parser callbacks From e7eba833bbea5da7f581636fc46e67cb9e44c321 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Tue, 10 Mar 2026 16:03:43 +0200 Subject: [PATCH 15/15] matrix test npm and binary --- .github/workflows/test.yml | 7 +++-- docs/AGENTS.md | 6 ++-- docs/testing.md | 31 +++++++++++++++----- packages/cli/package.json | 2 ++ packages/cli/tests/cli/testkit/CLITestkit.ts | 26 +++++++++++----- 5 files changed, 53 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c65be78..3e21ea7e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,9 @@ on: jobs: test: runs-on: ubuntu-latest + strategy: + matrix: + runner: [npm, binary] defaults: run: working-directory: packages/cli @@ -39,8 +42,8 @@ jobs: run: bun run build - name: Build binaries + if: matrix.runner == 'binary' run: bun run build:binaries - name: Run tests - run: bun run test - + run: bun run test:${{ matrix.runner }} diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 9c4ee788..e5be20d1 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -42,11 +42,13 @@ Zero-dependency npm package. All runtime dependencies are bundled into `dist/ind ```bash bun install # Install dependencies bun run build # Bundle to dist/index.js + copy templates -bun run build:binaries # Compile standalone binaries (required before tests) +bun run build:binaries # Compile standalone binaries (for binary test mode) bun run typecheck # tsc --noEmit bun run dev # Run bin/dev.ts (no build needed, Bun runs TS directly) bun run start # Run bin/run.js (requires build first) -bun run test # Run tests with vitest (use `bun run test`, not `bun test`) +bun run test # Run tests in npm mode (default; use `bun run test`, not `bun test`) +bun run test:npm # Run tests against node bin/run.js (needs build) +bun run test:binary # Run tests against compiled binary (needs build + build:binaries) bun run lint # Biome - lint and format check bun run lint:fix # Biome - auto-fix ``` diff --git a/docs/testing.md b/docs/testing.md index 0be98f99..3a81485a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,9 +1,10 @@ # Writing Tests -**Keywords:** test, vitest, testkit, setupCLITests, fixture, mock, Given/When/Then, BASE44_CLI_TEST_OVERRIDES, build before test, binary, TestAPIServer, Express +**Keywords:** test, vitest, testkit, setupCLITests, fixture, mock, Given/When/Then, BASE44_CLI_TEST_OVERRIDES, CLI_TEST_RUNNER, build before test, binary, npm, TestAPIServer, Express ## Table of Contents +- [Test Runner Modes](#test-runner-modes) - [How Testing Works](#how-testing-works) - [Test Structure](#test-structure) - [Writing a Test](#writing-a-test) @@ -14,21 +15,37 @@ --- -**Build before testing**: Tests spawn the compiled binary, so always build both the bundle and binaries: +## Test Runner Modes + +Tests can run against two different executables, controlled by the `CLI_TEST_RUNNER` env var: + +| Mode | Env var | Build required | What it tests | +|------|---------|----------------|---------------| +| **npm** (default) | `CLI_TEST_RUNNER=npm` | `bun run build` | The JS bundle via `node bin/run.js` (what npm users get) | +| **binary** | `CLI_TEST_RUNNER=binary` | `bun run build && bun run build:binaries` | The compiled standalone binary (what Homebrew users get) | ```bash -bun run build && bun run build:binaries && bun run test +# Quick local iteration (npm mode, default) +bun run build && bun run test + +# Explicit npm mode +bun run build && bun run test:npm + +# Binary mode +bun run build && bun run build:binaries && bun run test:binary ``` +CI runs both modes in parallel via matrix strategy. + ## How Testing Works -Tests spawn the **real compiled binary** as a child process and communicate via stdout/stderr/exit code. A lightweight **Express HTTP server** (`TestAPIServer`) runs locally to simulate the Base44 API — the binary is pointed at it via `BASE44_API_URL`. +Tests spawn the CLI as a **child process** and communicate via stdout/stderr/exit code. A lightweight **Express HTTP server** (`TestAPIServer`) runs locally to simulate the Base44 API — the CLI is pointed at it via `BASE44_API_URL`. This means: - Tests exercise the full CLI pipeline (argument parsing, error handling, output formatting) -- **`vi.mock()` won't work** — the binary is a standalone executable, not an in-process import +- **`vi.mock()` won't work** — the CLI runs as a separate process, not an in-process import - Use the **`BASE44_CLI_TEST_OVERRIDES` env var** for injecting test behavior (see below) -- Always `bun run build && bun run build:binaries` before `bun run test` to ensure the binary is fresh +- Always build before testing (see [Test Runner Modes](#test-runner-modes)) - Tests always run with `CI=true` (no TTY), so browser opens and animations are skipped ## Test Structure @@ -339,7 +356,7 @@ function getTestOverride(): MyType | undefined { ## Testing Rules -1. **Build first** -- Always `bun run build && bun run build:binaries` before `bun run test` +1. **Build first** -- Always `bun run build` before testing; add `bun run build:binaries` for binary mode 2. **Use fixtures** -- Don't create project structures in tests; use `tests/fixtures/` 3. **Fixtures need `.app.jsonc`** -- Add `base44/.app.jsonc` with `{ "id": "test-app-id" }` 4. **Interactive prompts can't be tested** -- Only test via non-interactive flags diff --git a/packages/cli/package.json b/packages/cli/package.json index 0212b0d9..197db1a8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,6 +19,8 @@ "start": "./bin/run.js", "clean": "rm -rf dist && mkdir -p dist", "test": "vitest run", + "test:npm": "CLI_TEST_RUNNER=npm vitest run", + "test:binary": "CLI_TEST_RUNNER=binary vitest run", "test:watch": "vitest", "lint": "cd ../.. && bun run lint", "lint:fix": "cd ../.. && bun run lint:fix", diff --git a/packages/cli/tests/cli/testkit/CLITestkit.ts b/packages/cli/tests/cli/testkit/CLITestkit.ts index 639d1d8c..acd45994 100644 --- a/packages/cli/tests/cli/testkit/CLITestkit.ts +++ b/packages/cli/tests/cli/testkit/CLITestkit.ts @@ -8,6 +8,12 @@ import { CLIResultMatcher } from "./CLIResultMatcher.js"; import { TestAPIServer } from "./TestAPIServer.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = join(__dirname, "../../.."); + +type TestRunnerMode = "npm" | "binary"; + +const TEST_RUNNER: TestRunnerMode = + (process.env.CLI_TEST_RUNNER as TestRunnerMode) || "npm"; /** Resolve the platform-specific compiled binary path */ function getBinaryPath(): string { @@ -19,13 +25,13 @@ function getBinaryPath(): string { : "linux"; const arch = process.arch === "arm64" ? "arm64" : "x64"; const ext = process.platform === "win32" ? ".exe" : ""; - return join( - __dirname, - `../../../dist/binaries/base44-${platform}-${arch}${ext}`, - ); + return join(CLI_ROOT, `dist/binaries/base44-${platform}-${arch}${ext}`); } -const BINARY_PATH = getBinaryPath(); +/** Resolve the npm entry point (node bin/run.js) */ +function getNpmEntryPath(): string { + return join(CLI_ROOT, "bin/run.js"); +} /** Test overrides that get serialized to BASE44_CLI_TEST_OVERRIDES */ interface TestOverrides { @@ -107,7 +113,7 @@ export class CLITestkit { // ─── WHEN METHODS ───────────────────────────────────────────── - /** Spawn the real compiled binary and execute the CLI command */ + /** Spawn the CLI as a child process and execute the command */ async run(...args: string[]): Promise { this.setupEnvOverrides(); @@ -117,10 +123,14 @@ export class CLITestkit { PATH: process.env.PATH ?? "", }; - // Register all pending mock routes before spawning the binary this.api.apply(); - const result = await execa(BINARY_PATH, args, { + const execArgs = + TEST_RUNNER === "binary" + ? { file: getBinaryPath(), args } + : { file: "node", args: [getNpmEntryPath(), ...args] }; + + const result = await execa(execArgs.file, execArgs.args, { cwd: this.projectDir ?? this.tempDir, env, reject: false,