diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b04cea49..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 @@ -38,6 +41,9 @@ jobs: - name: Build run: bun run build - - name: Run tests - run: bun run test + - name: Build binaries + if: matrix.runner == 'binary' + run: bun run build:binaries + - name: Run tests + run: bun run test:${{ matrix.runner }} diff --git a/bun.lock b/bun.lock index ce978def..895a8a94 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "base44-cli", @@ -45,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 +685,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=="], @@ -903,6 +903,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=="], @@ -911,8 +913,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=="], diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 1ea9eb7e..e5be20d1 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -40,14 +40,17 @@ 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 (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 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 ``` **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..3a81485a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,41 +1,59 @@ # 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, 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) - [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: +## 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 +# 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 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 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: -- **`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 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 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 ``` 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 +109,7 @@ describe(" command", () => { // Then t.expectResult(result).toFail(); - t.expectResult(result).toContainInStderr("Server error"); + t.expectResult(result).toContain("Server error"); }); }); ``` @@ -100,7 +118,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 +166,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 +194,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 +241,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 +265,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 +278,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 +312,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 +356,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` 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 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 diff --git a/packages/cli/package.json b/packages/cli/package.json index 6e3452ab..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", @@ -67,7 +69,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..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/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/Base44APIMock.ts b/packages/cli/tests/cli/testkit/Base44APIMock.ts deleted file mode 100644 index 6495b663..00000000 --- a/packages/cli/tests/cli/testkit/Base44APIMock.ts +++ /dev/null @@ -1,510 +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 AvailableIntegrationsListResponse { - integrations: Array<{ - integration_type: string; - display_name: string; - description: string; - connection_config_fields: Array<{ - name: string; - display_name: string; - description: string; - placeholder: string; - required: boolean; - validation_pattern?: string | null; - validation_error?: string | null; - }>; - }>; -} - -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 GET /api/apps/{appId}/external-auth/available-integrations - List available integrations */ - mockAvailableIntegrationsList( - response: AvailableIntegrationsListResponse, - ): this { - this.handlers.push( - http.get( - `${BASE_URL}/api/apps/${this.appId}/external-auth/available-integrations`, - () => HttpResponse.json(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 available integrations list to return an error */ - mockAvailableIntegrationsListError(error: ErrorResponse): this { - return this.mockError( - "get", - `/api/apps/${this.appId}/external-auth/available-integrations`, - 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/CLITestkit.ts b/packages/cli/tests/cli/testkit/CLITestkit.ts index 0e2a8efd..acd45994 100644 --- a/packages/cli/tests/cli/testkit/CLITestkit.ts +++ b/packages/cli/tests/cli/testkit/CLITestkit.ts @@ -1,31 +1,36 @@ -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 type { CLIResult } from "./CLIResultMatcher.js"; import { CLIResultMatcher } from "./CLIResultMatcher.js"; +import { TestAPIServer } from "./TestAPIServer.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"); - -/** Type for CLIContext */ -interface CLIContext { - errorReporter: { - setContext: (context: Record) => void; - getErrorContext: () => { sessionId?: string; appId?: string }; - }; - isNonInteractive: boolean; +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 { + 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(CLI_ROOT, `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 }; +/** 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 */ @@ -42,17 +47,17 @@ 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; + /** Real HTTP server for Base44 API endpoints */ + readonly api: TestAPIServer; private constructor( tempDir: string, cleanupFn: () => Promise, - appId: string, + api: TestAPIServer, ) { 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 +67,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 }); - return new CLITestkit(path, cleanup, appId); + const api = new TestAPIServer(appId); + await api.start(); + return new CLITestkit(path, cleanup, api); } /** Get the temp directory path */ @@ -106,93 +113,39 @@ export class CLITestkit { // ─── WHEN METHODS ───────────────────────────────────────────── - /** Execute CLI command */ + /** Spawn the CLI as a child process and execute the command */ async run(...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(); + const env: Record = { + ...this.env, + BASE44_API_URL: this.api.baseUrl, + PATH: process.env.PATH ?? "", + }; - // 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 execArgs = + TEST_RUNNER === "binary" + ? { file: getBinaryPath(), args } + : { file: "node", args: [getNpmEntryPath(), ...args] }; - const buildResult = (exitCode: number): CLIResult => ({ - stdout: stdout.join(""), - stderr: stderr.join(""), - exitCode, + const result = await execa(execArgs.file, execArgs.args, { + cwd: this.projectDir ?? this.tempDir, + env, + reject: false, + all: false, }); - 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(); - } + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode ?? 1, + }; } // ─── PRIVATE HELPERS ─────────────────────────────────────────── - private setupCwdMock(): void { - if (this.projectDir) { - vi.spyOn(process, "cwd").mockReturnValue(this.projectDir); - } - } - private setupEnvOverrides(): void { if (this.projectDir) { this.testOverrides.appConfig = { @@ -205,72 +158,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 */ @@ -317,6 +204,7 @@ export class CLITestkit { // ─── CLEANUP ────────────────────────────────────────────────── async cleanup(): Promise { + await this.api.stop(); await this.cleanupFn(); } } diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts new file mode 100644 index 00000000..6d98ec65 --- /dev/null +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -0,0 +1,511 @@ +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 AvailableIntegrationsListResponse { + integrations: Array<{ + integration_type: string; + display_name: string; + description: string; + connection_config_fields: Array<{ + name: string; + display_name: string; + description: string; + placeholder: string; + required: boolean; + validation_pattern?: string | null; + validation_error?: string | null; + }>; + }>; +} + +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 ──────────────────────────────────────────── + +/** + * Lightweight Express HTTP server used in integration tests to simulate the Base44 API. + * + * 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 + * const server = new TestAPIServer("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 TestAPIServer { + 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}`; + } + + // ─── 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 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, + ); + } + + mockAvailableIntegrationsList( + response: AvailableIntegrationsListResponse, + ): this { + return this.addRoute( + "GET", + `/api/apps/${this.appId}/external-auth/available-integrations`, + 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 { + 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; + } + + /** + * Register a custom Express handler for advanced scenarios (e.g. stateful + * responses that change behaviour across retries). + */ + mockRoute( + method: Method, + path: string, + handler: (req: express.Request, res: express.Response) => void, + ): this { + this.pendingRoutes.push({ method, path, handler }); + return this; + } + + // ─── ERROR RESPONSES ───────────────────────────────────── + + 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, + ); + } + + mockAvailableIntegrationsListError(error: ErrorResponse): this { + return this.addErrorRoute( + "GET", + `/api/apps/${this.appId}/external-auth/available-integrations`, + error, + ); + } +} diff --git a/packages/cli/tests/cli/testkit/index.ts b/packages/cli/tests/cli/testkit/index.ts index 24ff6c6f..5ca73764 100644 --- a/packages/cli/tests/cli/testkit/index.ts +++ b/packages/cli/tests/cli/testkit/index.ts @@ -1,13 +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"); -export const mswServer = setupServer(); - /** Resolve a fixture path by name */ export function fixture(name: string): string { return resolve(FIXTURES_DIR, name); @@ -92,26 +89,17 @@ export function setupCLITests(): TestContext { return currentKit; }; - beforeAll(() => { - mswServer.listen({ onUnhandledRequest: "bypass" }); - }); - beforeEach(async () => { currentKit = await CLITestkit.create(); }); afterEach(async () => { - mswServer.resetHandlers(); if (currentKit) { await currentKit.cleanup(); currentKit = null; } }); - afterAll(() => { - mswServer.close(); - }); - // Default user for givenLoggedInWithProject const defaultUser = { email: "test@example.com", name: "Test User" }; @@ -146,8 +134,8 @@ export function setupCLITests(): TestContext { }; } -export { Base44APIMock } from "./Base44APIMock.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"; diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 46a91b01..d1835967 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ environment: "node", globals: true, include: ["tests/**/*.spec.ts"], - testTimeout: 30000, +testTimeout: 30000, mockReset: true, silent: true, // Suppress stdout/stderr from tests (CLI output is very noisy) },