UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

485 lines (419 loc) 13.6 kB
import { Alepha } from "alepha"; import { DateTimeProvider } from "alepha/datetime"; import { FileSystemProvider, MemoryFileSystemProvider, MemoryShellProvider, ShellProvider, } from "alepha/system"; import { describe, test } from "vitest"; import type { PlatformContext } from "../adapters/PlatformAdapter.ts"; import { VercelAdapter } from "../adapters/VercelAdapter.ts"; import { NamingService } from "../services/NamingService.ts"; import { VercelApi } from "../services/VercelApi.ts"; import { VercelCli } from "../services/VercelCli.ts"; /** * In-memory VercelApi for testing. * * Stores resources in maps and implements the same interface * without making any HTTP calls. */ class MemoryVercelApi extends VercelApi { public projects: Array<{ id: string; name: string; accountId: string }> = []; public deployments: Map< string, Array<{ uid: string; name: string; url: string; created?: number; target?: string; }> > = new Map(); public envVars: Map< string, Array<{ id: string; key: string; value?: string; type: string; target: string[]; }> > = new Map(); public override async resolveToken(): Promise<string> { return "test-token"; } public override async listProjects() { return this.projects; } public override async getProject(nameOrId: string) { return this.projects.find((p) => p.name === nameOrId || p.id === nameOrId); } public override async createProject(name: string) { const project = { id: `proj-${name}`, name, accountId: "test-account" }; this.projects.push(project); return project; } public override async deleteProject(nameOrId: string) { this.projects = this.projects.filter( (p) => p.name !== nameOrId && p.id !== nameOrId, ); } public override async listDeployments(projectId: string) { return this.deployments.get(projectId) ?? []; } public override async listEnvVars(projectId: string) { return this.envVars.get(projectId) ?? []; } public override async upsertEnvVars( projectId: string, vars: Array<{ key: string; value: string; target: string[] }>, ) { const existing = this.envVars.get(projectId) ?? []; for (const v of vars) { const idx = existing.findIndex((e) => e.key === v.key); const entry = { id: `env-${v.key}`, key: v.key, value: v.value, type: "encrypted", target: v.target, }; if (idx >= 0) { existing[idx] = entry; } else { existing.push(entry); } } this.envVars.set(projectId, existing); } public override async deleteEnvVar(projectId: string, envVarId: string) { const existing = this.envVars.get(projectId) ?? []; this.envVars.set( projectId, existing.filter((e) => e.id !== envVarId), ); } } /** * In-memory VercelCli for testing. * * Stubs CLI operations without spawning processes. */ class MemoryVercelCli extends VercelCli { public authToken = "test-token"; public lastDeployDir?: string; public lastDeployOptions?: { prod?: boolean; token?: string }; public override async ensureInstalled(): Promise<void> {} public override async getAuthToken(): Promise<string> { return this.authToken; } public override async whoami(): Promise<string> { return "test-user"; } public override async login(): Promise<void> {} public override async deploy( distDir: string, options: { prod?: boolean; token?: string }, ): Promise<string | undefined> { this.lastDeployDir = distDir; this.lastDeployOptions = options; return "https://my-app.vercel.app"; } } describe("VercelAdapter", () => { const createTestEnv = () => { const alepha = Alepha.create() .with({ provide: FileSystemProvider, use: MemoryFileSystemProvider }) .with({ provide: ShellProvider, use: MemoryShellProvider }) .with({ provide: VercelApi, use: MemoryVercelApi }) .with({ provide: VercelCli, use: MemoryVercelCli }); const fs = alepha.inject(MemoryFileSystemProvider); const shell = alepha.inject(MemoryShellProvider); const dateTime = alepha.inject(DateTimeProvider); const adapter = alepha.inject(VercelAdapter); const naming = alepha.inject(NamingService); const api = alepha.inject(MemoryVercelApi); const cli = alepha.inject(MemoryVercelCli); // Pre-seed package.json so ensureDependency finds vercel already installed fs.files.set( "/project/package.json", Buffer.from( JSON.stringify({ name: "test", devDependencies: { vercel: "^35.0.0" }, }), ), ); return { alepha, fs, shell, dateTime, adapter, naming, api, cli }; }; const makeCtx = ( naming: NamingService, overrides: Partial<PlatformContext> = {}, ): PlatformContext => ({ project: "acme-portal", env: "production", envConfig: { adapter: "vercel" }, entry: { root: "/project", server: "src/main.ts" }, resources: { hasDatabase: false, hasBucket: false, hasKV: false, hasQueue: false, hasCron: false, }, root: "/project", naming: naming.forContext("acme-portal", "production"), ...overrides, }); describe("authenticate", () => { test("skips login when token exists and whoami succeeds", async ({ expect, }) => { const { adapter, naming, cli } = createTestEnv(); const ctx = makeCtx(naming); let loginCalled = false; const originalLogin = cli.login.bind(cli); cli.login = async () => { loginCalled = true; return originalLogin(); }; const run = createMockRun(); await adapter.authenticate(ctx, run); expect(loginCalled).toBe(false); }); test("cache fresh: second call skips recordLogin", async ({ expect }) => { const { adapter, fs, dateTime, naming } = createTestEnv(); const ctx = makeCtx(naming); dateTime.pause(); const run = createMockRun(); await adapter.authenticate(ctx, run); // First call should have written the cache file const cachePath = "/project/node_modules/.alepha/platform.json"; expect(fs.wasWritten(cachePath)).toBe(true); // Reset tracking to detect second write fs.writeFileCalls.length = 0; await adapter.authenticate(ctx, run); // Second call — cache is fresh, recordLogin is skipped expect(fs.wasWritten(cachePath)).toBe(false); }); }); describe("secrets", () => { test("pushes non-excluded env vars via VercelApi.upsertEnvVars", async ({ expect, }) => { const { adapter, fs, naming, api } = createTestEnv(); const ctx = makeCtx(naming, { entry: { root: "/project", server: "src/main.ts" }, resources: { hasDatabase: false, hasBucket: false, hasKV: false, hasQueue: false, hasCron: false, }, }); // Pre-seed the project so upsertEnvVars has a target api.projects.push({ id: "proj-acme", name: "acme-portal-production", accountId: "test-account", }); // Write .env.production with a mix of secrets and excluded vars await fs.writeFile( "/project/.env.production", [ "GOOGLE_API_KEY=sk-123", "APP_SECRET=my-secret", "VITE_PUBLIC_KEY=public-abc", "NODE_ENV=production", "", ].join("\n"), ); const run = createMockRun(); await adapter.secrets(ctx, run); const pushed = api.envVars.get("acme-portal-production") ?? []; const pushedKeys = pushed.map((v) => v.key); // Should include non-excluded vars expect(pushedKeys).toContain("GOOGLE_API_KEY"); expect(pushedKeys).toContain("APP_SECRET"); // Excluded vars should NOT be pushed expect(pushedKeys).not.toContain("VITE_PUBLIC_KEY"); expect(pushedKeys).not.toContain("NODE_ENV"); }); test("filters out VITE_* and NODE_ENV", async ({ expect }) => { const { adapter, fs, naming, api } = createTestEnv(); const ctx = makeCtx(naming, { entry: { root: "/project", server: "src/main.ts" }, resources: { hasDatabase: false, hasBucket: false, hasKV: false, hasQueue: false, hasCron: false, }, }); api.projects.push({ id: "proj-acme", name: "acme-portal-production", accountId: "test-account", }); await fs.writeFile( "/project/.env.production", [ "VITE_API_URL=https://api.example.com", "VITE_PUBLIC_KEY=abc", "NODE_ENV=production", "REAL_SECRET=keep-me", "", ].join("\n"), ); const run = createMockRun(); await adapter.secrets(ctx, run); const pushed = api.envVars.get("acme-portal-production") ?? []; const pushedKeys = pushed.map((v) => v.key); expect(pushedKeys).toEqual(["REAL_SECRET"]); }); test("skips when no env file exists", async ({ expect }) => { const { adapter, naming, api } = createTestEnv(); const ctx = makeCtx(naming, { entry: { root: "/project", server: "src/main.ts" }, resources: { hasDatabase: false, hasBucket: false, hasKV: false, hasQueue: false, hasCron: false, }, }); let upsertCalled = false; const originalUpsert = api.upsertEnvVars.bind(api); api.upsertEnvVars = async ( ...args: Parameters<typeof api.upsertEnvVars> ) => { upsertCalled = true; return originalUpsert(...args); }; const run = createMockRun(); await adapter.secrets(ctx, run); expect(upsertCalled).toBe(false); }); }); describe("inspect", () => { test("returns project state with deployment info", async ({ expect }) => { const { adapter, naming, api } = createTestEnv(); const ctx = makeCtx(naming, { entry: { root: "/project", server: "src/main.ts" }, resources: { hasDatabase: false, hasBucket: false, hasKV: false, hasQueue: false, hasCron: false, }, }); // Pre-seed project and deployments api.projects.push({ id: "proj-acme", name: "acme-portal-production", accountId: "test-account", }); api.deployments.set("proj-acme", [ { uid: "dpl-abc123", name: "acme-portal-production", url: "acme-portal-production.vercel.app", created: 1700000000000, target: "production", }, ]); const run = createMockRun(); const state = await adapter.inspect(ctx, run); expect(state.workers).toHaveLength(1); expect(state.workers[0].name).toBe("acme-portal-production"); expect(state.workers[0].exists).toBe(true); expect(state.workers[0].version).toBe("dpl-abc123"); }); test("returns env var deployment status", async ({ expect }) => { const { adapter, fs, naming, api } = createTestEnv(); const ctx = makeCtx(naming, { entry: { root: "/project", server: "src/main.ts" }, resources: { hasDatabase: false, hasBucket: false, hasKV: false, hasQueue: false, hasCron: false, }, }); api.projects.push({ id: "proj-acme", name: "acme-portal-production", accountId: "test-account", }); // Some env vars deployed, some not api.envVars.set("acme-portal-production", [ { id: "env-1", key: "GOOGLE_API_KEY", type: "encrypted", target: ["production"], }, ]); await fs.writeFile( "/project/.env.production", ["GOOGLE_API_KEY=sk-123", "MISSING_SECRET=value", ""].join("\n"), ); const run = createMockRun(); const state = await adapter.inspect(ctx, run); expect(state.secrets).toHaveLength(2); const deployed = state.secrets.find((s) => s.name === "GOOGLE_API_KEY"); expect(deployed?.deployed).toBe(true); const missing = state.secrets.find((s) => s.name === "MISSING_SECRET"); expect(missing?.deployed).toBe(false); }); }); describe("teardown", () => { test("deletes projects via VercelApi.deleteProject", async ({ expect }) => { const { adapter, naming, api } = createTestEnv(); const ctx = makeCtx(naming, { entry: { root: "/project", server: "src/main.ts" }, resources: { hasDatabase: false, hasBucket: false, hasKV: false, hasQueue: false, hasCron: false, }, }); // Pre-seed project api.projects.push({ id: "proj-acme", name: "acme-portal-production", accountId: "test-account", }); const run = createMockRun(); await adapter.teardown(ctx, run); expect(api.projects).toHaveLength(0); }); }); }); /** * Create a mock RunnerMethod that just executes handlers directly. */ function createMockRun(): any { const run: any = async (task: any) => { if (Array.isArray(task)) { await Promise.all( task.map((t) => typeof t === "object" && t.handler ? t.handler() : Promise.resolve(), ), ); } if (typeof task === "object" && task.handler) { await task.handler(); } }; run.end = () => {}; return run; }