alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
485 lines (419 loc) • 13.6 kB
text/typescript
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;
}