diff --git a/AGENTS.md b/AGENTS.md index 7325a49..a7aa526 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -841,9 +841,53 @@ t.api.mockAgentsPushError({ status: 401, body: { error: "..." } }); t.api.mockSiteDeployError({ status: 413, body: { error: "..." } }); ``` +### Test Overrides (`BASE44_CLI_TEST_OVERRIDES`) + +The CLI uses a centralized JSON-based override mechanism for tests. When adding new testable behaviors that need mocking, **extend this existing mechanism** rather than creating new environment variables. + +**Current overrides:** +- `appConfig` - Mock app configuration (id, projectRoot) +- `latestVersion` - Mock version check response (string for newer version, null for no update) + +**Adding new overrides:** + +1. Add the field to `TestOverrides` interface in `CLITestkit.ts`: +```typescript +interface TestOverrides { + appConfig?: { id: string; projectRoot: string }; + latestVersion?: string | null; + myNewOverride?: MyType; // Add here +} +``` + +2. Add a `given*` method to `CLITestkit`: +```typescript +givenMyOverride(value: MyType): void { + this.testOverrides.myNewOverride = value; +} +``` + +3. Expose it in `testkit/index.ts` `TestContext` interface and implementation. + +4. Read the override in your source code: +```typescript +function getTestOverride(): MyType | undefined { + const overrides = process.env.BASE44_CLI_TEST_OVERRIDES; + if (!overrides) return undefined; + try { + return JSON.parse(overrides).myNewOverride; + } catch { + return undefined; + } +} +``` + +**Why not vi.mock()?** Tests run against the bundled `dist/index.js` where path aliases are resolved. `vi.mock("@/some/path.js")` won't match the bundled code. + ### Testing Rules 1. **Build first** - Run `npm run build` before `npm test` 2. **Use fixtures** - Don't create project structures in tests 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 diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index 211cce3..3d516d9 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -6,6 +6,7 @@ import { isCLIError } from "@/core/errors.js"; import { login } from "@/cli/commands/auth/login-flow.js"; import { printBanner } from "@/cli/utils/banner.js"; import { theme } from "@/cli/utils/theme.js"; +import { printUpgradeNotificationIfAvailable } from "@/cli/utils/upgradeNotification.js"; export interface RunCommandOptions { /** @@ -73,6 +74,8 @@ export async function runCommand( intro(theme.colors.base44OrangeBackground(" Base 44 ")); } + await printUpgradeNotificationIfAvailable(); + try { // Check authentication if required if (options?.requireAuth) { diff --git a/src/cli/utils/upgradeNotification.ts b/src/cli/utils/upgradeNotification.ts new file mode 100644 index 0000000..dd2e29b --- /dev/null +++ b/src/cli/utils/upgradeNotification.ts @@ -0,0 +1,25 @@ +import { log } from "@clack/prompts"; +import type { UpgradeInfo } from "@/cli/utils/version-check.js"; +import { checkForUpgrade } from "@/cli/utils/version-check.js"; +import { theme } from "@/cli/utils/theme.js"; + +function formatUpgradeMessage(info: UpgradeInfo): string { + const { shinyOrange } = theme.colors; + const { bold } = theme.styles; + + return `${shinyOrange("Update available!")} ${shinyOrange(`${info.currentVersion} → ${info.latestVersion}`)} ${shinyOrange("Run:")} ${bold(shinyOrange("npm update -g base44"))}`; +} + +/** + * Checks for available upgrades and prints a notification if one exists. + */ +export async function printUpgradeNotificationIfAvailable(): Promise { + try { + const upgradeInfo = await checkForUpgrade(); + if (upgradeInfo) { + log.message(formatUpgradeMessage(upgradeInfo)); + } + } catch { + // Silently ignore errors + } +} diff --git a/src/cli/utils/version-check.ts b/src/cli/utils/version-check.ts new file mode 100644 index 0000000..b6fd2c9 --- /dev/null +++ b/src/cli/utils/version-check.ts @@ -0,0 +1,39 @@ +import { execa } from "execa"; +import packageJson from "../../../package.json"; +import { getTestOverrides } from "@/core/config.js"; + +export interface UpgradeInfo { + currentVersion: string; + latestVersion: string; +} + +export async function checkForUpgrade(): Promise { + const testLatestVersion = getTestOverrides()?.latestVersion; + if (testLatestVersion !== undefined) { + if (testLatestVersion === null) { + return null; + } + const currentVersion = packageJson.version; + if (testLatestVersion !== currentVersion) { + return { currentVersion, latestVersion: testLatestVersion }; + } + return null; + } + + try { + const { stdout } = await execa("npm", ["view", "base44", "version"], { + timeout: 500, + shell: true, + env: { CI: "1" }, + }); + const latestVersion = stdout.trim(); + const currentVersion = packageJson.version; + + if (latestVersion !== currentVersion) { + return { currentVersion, latestVersion }; + } + return null; + } catch { + return null; + } +} diff --git a/src/core/config.ts b/src/core/config.ts index f1849f5..d8cf76b 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -2,6 +2,7 @@ import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; import { PROJECT_SUBDIR } from "@/core/consts.js"; +import { TestOverridesSchema, type TestOverrides } from "@/core/project/schema.js"; // After bundling, import.meta.url points to dist/cli/index.js // Templates are copied to dist/cli/templates/ @@ -30,3 +31,17 @@ export function getAppConfigPath(projectRoot: string): string { export function getBase44ApiUrl(): string { return process.env.BASE44_API_URL || "https://app.base44.com"; } + +export function getTestOverrides(): TestOverrides | null { + const raw = process.env.BASE44_CLI_TEST_OVERRIDES; + if (!raw) { + return null; + } + try { + const parsed = JSON.parse(raw); + const result = TestOverridesSchema.safeParse(parsed); + return result.success ? result.data : null; + } catch { + return null; + } +} diff --git a/src/core/project/app-config.ts b/src/core/project/app-config.ts index ed9e4ee..ba4898e 100644 --- a/src/core/project/app-config.ts +++ b/src/core/project/app-config.ts @@ -1,5 +1,5 @@ import { globby } from "globby"; -import { getAppConfigPath } from "@/core/config.js"; +import { getAppConfigPath, getTestOverrides } from "@/core/config.js"; import { writeFile, readJsonFile } from "@/core/utils/fs.js"; import { APP_CONFIG_PATTERN } from "@/core/consts.js"; import { AppConfigSchema } from "@/core/project/schema.js"; @@ -18,24 +18,11 @@ export interface CachedAppConfig { let cache: CachedAppConfig | null = null; -/** - * Load app config from BASE44_CLI_TEST_OVERRIDES env var. - * @returns true if override was applied, false otherwise - */ function loadFromTestOverrides(): boolean { - const overrides = process.env.BASE44_CLI_TEST_OVERRIDES; - if (!overrides) { - return false; - } - - try { - const data = JSON.parse(overrides); - if (data.appConfig?.id && data.appConfig?.projectRoot) { - cache = { id: data.appConfig.id, projectRoot: data.appConfig.projectRoot }; - return true; - } - } catch { - // Invalid JSON, ignore + const appConfig = getTestOverrides()?.appConfig; + if (appConfig?.id && appConfig.projectRoot) { + cache = { id: appConfig.id, projectRoot: appConfig.projectRoot }; + return true; } return false; } diff --git a/src/core/project/schema.ts b/src/core/project/schema.ts index 431ae2f..fba151e 100644 --- a/src/core/project/schema.ts +++ b/src/core/project/schema.ts @@ -59,3 +59,13 @@ export type Project = z.infer; export const ProjectsResponseSchema = z.array(ProjectSchema); export type ProjectsResponse = z.infer; + +export const TestOverridesSchema = z.object({ + appConfig: z.object({ + id: z.string(), + projectRoot: z.string(), + }).optional(), + latestVersion: z.string().nullable().optional(), +}); + +export type TestOverrides = z.infer; diff --git a/tests/cli/testkit/CLIResultMatcher.ts b/tests/cli/testkit/CLIResultMatcher.ts index 8d831dd..d218137 100644 --- a/tests/cli/testkit/CLIResultMatcher.ts +++ b/tests/cli/testkit/CLIResultMatcher.ts @@ -43,6 +43,17 @@ export class CLIResultMatcher { } } + toNotContain(text: string): void { + const output = this.result.stdout + this.result.stderr; + if (output.includes(text)) { + throw new Error( + `Expected output NOT to contain "${text}"\n` + + `stdout: ${stripAnsi(this.result.stdout)}\n` + + `stderr: ${stripAnsi(this.result.stderr)}` + ); + } + } + toContainInStdout(text: string): void { if (!this.result.stdout.includes(text)) { throw new Error( diff --git a/tests/cli/testkit/CLITestkit.ts b/tests/cli/testkit/CLITestkit.ts index 8e16cee..4c1e71d 100644 --- a/tests/cli/testkit/CLITestkit.ts +++ b/tests/cli/testkit/CLITestkit.ts @@ -25,11 +25,18 @@ interface ProgramModule { CLIExitError: new (code: number) => Error & { code: number }; } +/** Test overrides that get serialized to BASE44_CLI_TEST_OVERRIDES */ +interface TestOverrides { + appConfig?: { id: string; projectRoot: string }; + latestVersion?: string | null; +} + export class CLITestkit { private tempDir: string; private cleanupFn: () => Promise; private env: Record = {}; private projectDir?: string; + private testOverrides: TestOverrides = {}; /** Typed API mock for Base44 endpoints */ readonly api: Base44APIMock; @@ -79,6 +86,10 @@ export class CLITestkit { await cp(fixturePath, this.projectDir, { recursive: true }); } + givenLatestVersion(version: string | null): void { + this.testOverrides.latestVersion = version; + } + // ─── WHEN METHODS ───────────────────────────────────────────── /** Execute CLI command */ @@ -159,25 +170,25 @@ export class CLITestkit { private setupEnvOverrides(): void { if (this.projectDir) { - this.env.BASE44_CLI_TEST_OVERRIDES = JSON.stringify({ - appConfig: { id: this.api.appId, projectRoot: this.projectDir }, - }); + this.testOverrides.appConfig = { id: this.api.appId, projectRoot: this.projectDir }; + } + if (Object.keys(this.testOverrides).length > 0) { + this.env.BASE44_CLI_TEST_OVERRIDES = JSON.stringify(this.testOverrides); } } /** Save original values of env vars we're about to modify */ - private captureEnvSnapshot(): { HOME?: string; BASE44_CLI_TEST_OVERRIDES?: string; CI?: string; BASE44_DISABLE_TELEMETRY?: string } { - return { - HOME: process.env.HOME, - BASE44_CLI_TEST_OVERRIDES: process.env.BASE44_CLI_TEST_OVERRIDES, - CI: process.env.CI, - BASE44_DISABLE_TELEMETRY: process.env.BASE44_DISABLE_TELEMETRY, - }; + private captureEnvSnapshot(): Record { + const snapshot: Record = {}; + for (const key of Object.keys(this.env)) { + snapshot[key] = process.env[key]; + } + return snapshot; } /** Restore env vars to their original values (or delete if they didn't exist) */ - private restoreEnvSnapshot(snapshot: { HOME?: string; BASE44_CLI_TEST_OVERRIDES?: string; CI?: string; BASE44_DISABLE_TELEMETRY?: string }): void { - for (const key of ["HOME", "BASE44_CLI_TEST_OVERRIDES", "CI", "BASE44_DISABLE_TELEMETRY"] as const) { + private restoreEnvSnapshot(snapshot: Record): void { + for (const key of Object.keys(snapshot)) { if (snapshot[key] === undefined) { delete process.env[key]; } else { diff --git a/tests/cli/testkit/index.ts b/tests/cli/testkit/index.ts index 36bfdb2..909e598 100644 --- a/tests/cli/testkit/index.ts +++ b/tests/cli/testkit/index.ts @@ -32,6 +32,8 @@ export interface TestContext { /** Combined: login + project setup (most common pattern) */ givenLoggedInWithProject: (fixturePath: string, user?: { email: string; name: string }) => Promise; + givenLatestVersion: (version: string | null) => void; + // ─── WHEN METHODS ────────────────────────────────────────── /** Execute CLI command */ @@ -112,6 +114,7 @@ export function setupCLITests(): TestContext { await getKit().givenLoggedIn(user); await getKit().givenProject(fixturePath); }, + givenLatestVersion: (version) => getKit().givenLatestVersion(version), // When methods run: (...args) => getKit().run(...args), diff --git a/tests/cli/version-check.spec.ts b/tests/cli/version-check.spec.ts new file mode 100644 index 0000000..964fa30 --- /dev/null +++ b/tests/cli/version-check.spec.ts @@ -0,0 +1,36 @@ +import { describe, it } from "vitest"; +import { setupCLITests } from "./testkit/index.js"; + +describe("upgrade notification", () => { + const t = setupCLITests(); + + it("displays upgrade notification when newer version is available", async () => { + t.givenLatestVersion("1.0.0"); + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("whoami"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Update available!"); + t.expectResult(result).toContain("1.0.0"); + t.expectResult(result).toContain("npm update -g base44"); + }); + + it("does not display notification when version is current", async () => { + t.givenLatestVersion(null); + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("whoami"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toNotContain("Update available!"); + }); + + it("does not display notification when check is not overridden", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("whoami"); + + t.expectResult(result).toSucceed(); + }); +});