Skip to content
File

Blob: tests/helpers/runtime.ts

typescript279 lines
1import { SELF, applyD1Migrations, env, listDurableObjectIds, runInDurableObject } from "cloudflare:test";
2import { asc } from "drizzle-orm";
3 
4import {
5 BranchName,
6 DEFAULT_DISPATCH_MODE,
7 DEFAULT_EXECUTION_RUNTIME,
8 type DispatchMode,
9 OwnerSlug,
10 ProjectId,
11 ProjectSlug,
12 UserId,
13} from "@/contracts";
14import { createSession } from "@/worker/auth";
15import { SESSION_COOKIE_WIRE_NAME } from "@/worker/auth/cookies";
16import { createD1Db } from "@/worker/db/d1";
17import * as d1Schema from "@/worker/db/d1/schema";
18import * as projectDoSchema from "@/worker/db/durable/schema/project-do";
19import { ProjectDO } from "@/worker/durable";
20import type { ProjectDoContext } from "@/worker/durable/project-do/types";
21import { encryptSecret } from "@/worker/security/secrets";
22import { generateDurableEntityId } from "@/worker/services";
23 
24import d1MigrationJournal from "../../drizzle/d1/meta/_journal.json";
25 
26const d1MigrationFiles = import.meta.glob("../../drizzle/d1/*.sql", {
27 query: "?raw",
28 import: "default",
29 eager: true,
30}) as Record<string, string>;
31 
32const DEFAULT_TEST_ORIGIN = "https://example.com";
33const LOOPBACK_TEST_ORIGIN = "http://127.0.0.1";
34 
35interface MigrationJournalEntry {
36 tag: string;
37}
38 
39const splitMigrationStatements = (sql: string): string[] =>
40 sql
41 .split("--> statement-breakpoint")
42 .map((query) => query.trim())
43 .filter((query) => query.length > 0);
44 
45export const readAppD1Migrations = () =>
46 d1MigrationJournal.entries.map((entry: MigrationJournalEntry) => {
47 const fileName = `${entry.tag}.sql`;
48 const filePath = `../../drizzle/d1/${fileName}`;
49 const sql = d1MigrationFiles[filePath];
50 
51 if (!sql) {
52 throw new Error(`Missing D1 migration file for ${fileName}.`);
53 }
54 
55 return {
56 name: fileName,
57 queries: splitMigrationStatements(sql),
58 };
59 });
60 
61export interface SeededUser {
62 id: UserId;
63 slug: OwnerSlug;
64 email: string;
65 displayName: string;
66}
67 
68export interface SeededProject {
69 id: ProjectId;
70 ownerUserId: UserId;
71 ownerSlug: OwnerSlug;
72 projectSlug: ProjectSlug;
73 name: string;
74 repoUrl: string;
75 defaultBranch: BranchName;
76 configPath: string;
77}
78 
79interface SeedUserOverrides {
80 id?: string;
81 slug?: string;
82 email?: string;
83 displayName?: string;
84}
85 
86interface SeedProjectOverrides {
87 id?: string;
88 projectSlug?: string;
89 name?: string;
90 repoUrl?: string;
91 defaultBranch?: string;
92 configPath?: string;
93 dispatchMode?: DispatchMode;
94 repoToken?: string | null;
95}
96 
97export interface ProjectDoSnapshot {
98 state: typeof projectDoSchema.projectState.$inferSelect | null;
99 config: typeof projectDoSchema.projectConfig.$inferSelect | null;
100 runs: Array<typeof projectDoSchema.projectRuns.$inferSelect>;
101 webhooks: Array<typeof projectDoSchema.projectWebhooks.$inferSelect>;
102 webhookDeliveries: Array<typeof projectDoSchema.projectWebhookDeliveries.$inferSelect>;
103}
104 
105export interface JsonFetchResult<T> {
106 response: Response;
107 status: number;
108 body: T | null;
109 text: string;
110}
111 
112export const getDb = () => createD1Db(env.DB);
113 
114export const applyAppMigrations = async (): Promise<void> => {
115 await applyD1Migrations(env.DB, readAppD1Migrations());
116};
117 
118export const seedUser = async (overrides: SeedUserOverrides = {}): Promise<SeededUser> => {
119 const db = getDb();
120 const now = Date.now();
121 const user = {
122 id: overrides.id ? UserId.assertDecode(overrides.id) : UserId.assertDecode(generateDurableEntityId("usr", now)),
123 slug: overrides.slug ? OwnerSlug.assertDecode(overrides.slug) : OwnerSlug.assertDecode("tester"),
124 email: overrides.email ?? "tester@example.com",
125 displayName: overrides.displayName ?? "Test Operator",
126 };
127 
128 await db.insert(d1Schema.users).values({
129 id: user.id,
130 slug: user.slug,
131 email: user.email,
132 displayName: user.displayName,
133 createdAt: now,
134 disabledAt: null,
135 });
136 
137 return user;
138};
139 
140export const seedProject = async (
141 owner: Pick<SeededUser, "id" | "slug">,
142 overrides: SeedProjectOverrides = {},
143): Promise<SeededProject> => {
144 const db = getDb();
145 const now = Date.now();
146 const encryptedToken = typeof overrides.repoToken === "string" ? await encryptSecret(env, overrides.repoToken) : null;
147 
148 const project = {
149 id: overrides.id
150 ? ProjectId.assertDecode(overrides.id)
151 : ProjectId.assertDecode(generateDurableEntityId("prj", now)),
152 ownerUserId: owner.id,
153 ownerSlug: owner.slug,
154 projectSlug: overrides.projectSlug
155 ? ProjectSlug.assertDecode(overrides.projectSlug)
156 : ProjectSlug.assertDecode("anvil-spec"),
157 name: overrides.name ?? "Anvil Spec",
158 repoUrl: overrides.repoUrl ?? "https://github.com/example/anvil-spec",
159 defaultBranch: overrides.defaultBranch
160 ? BranchName.assertDecode(overrides.defaultBranch)
161 : BranchName.assertDecode("main"),
162 configPath: overrides.configPath ?? ".anvil.yml",
163 };
164 
165 await db.insert(d1Schema.projectIndex).values({
166 ...project,
167 createdAt: now,
168 updatedAt: now,
169 });
170 
171 await env.PROJECT_DO.getByName(project.id).initializeProject({
172 projectId: project.id,
173 name: project.name,
174 repoUrl: project.repoUrl,
175 defaultBranch: project.defaultBranch,
176 configPath: project.configPath,
177 encryptedRepoToken: encryptedToken,
178 dispatchMode: overrides.dispatchMode ?? DEFAULT_DISPATCH_MODE,
179 executionRuntime: DEFAULT_EXECUTION_RUNTIME,
180 createdAt: now,
181 updatedAt: now,
182 });
183 
184 return project;
185};
186 
187export const createAuthenticatedSession = async (userId: UserId): Promise<string> => {
188 const { sessionId } = await createSession(env, userId, new Date(Date.now()));
189 return sessionId;
190};
191 
192export const authHeaders = (sessionId: string, headers?: HeadersInit): Headers => {
193 const result = new Headers(headers);
194 result.set("cookie", `${SESSION_COOKIE_WIRE_NAME}=${sessionId}`);
195 if (!result.has("origin")) {
196 result.set("origin", DEFAULT_TEST_ORIGIN);
197 }
198 return result;
199};
200 
201export const mintCookieAuth = async (
202 userId: UserId,
203 headers?: HeadersInit,
204): Promise<{ sessionId: string; headers: Headers }> => {
205 const sessionId = await createAuthenticatedSession(userId);
206 return { sessionId, headers: authHeaders(sessionId, headers) };
207};
208 
209export const fetchJsonFromOrigin = async <T>(
210 origin: string,
211 path: string,
212 init: RequestInit = {},
213): Promise<JsonFetchResult<T>> => {
214 const response = await SELF.fetch(`${origin}${path}`, init);
215 const text = await response.text();
216 
217 return {
218 response,
219 status: response.status,
220 body: text ? (JSON.parse(text) as T) : null,
221 text,
222 };
223};
224 
225export const fetchJson = async <T>(path: string, init: RequestInit = {}): Promise<JsonFetchResult<T>> =>
226 await fetchJsonFromOrigin(DEFAULT_TEST_ORIGIN, path, init);
227 
228export const getProjectStub = (projectId: ProjectId) => env.PROJECT_DO.getByName(projectId);
229 
230type ProjectDoDbAccessor = Pick<ProjectDoContext, "db">;
231type ProjectDoStateAccessor = Pick<ProjectDoContext, "ctx">;
232 
233export const runInProjectDo = async <T>(
234 projectId: ProjectId,
235 callback: (instance: ProjectDO) => Promise<T>,
236): Promise<T> => runInDurableObject(getProjectStub(projectId), callback);
237 
238export const readProjectDoRows = async (projectId: ProjectId): Promise<ProjectDoSnapshot> =>
239 runInProjectDo(projectId, async (instance: ProjectDO) => {
240 const { db } = instance as unknown as ProjectDoDbAccessor;
241 const stateRows = await db.select().from(projectDoSchema.projectState);
242 const configRows = await db.select().from(projectDoSchema.projectConfig);
243 const runRows = await db
244 .select()
245 .from(projectDoSchema.projectRuns)
246 .orderBy(asc(projectDoSchema.projectRuns.createdAt), asc(projectDoSchema.projectRuns.runId));
247 const webhookRows = await db
248 .select()
249 .from(projectDoSchema.projectWebhooks)
250 .orderBy(asc(projectDoSchema.projectWebhooks.provider), asc(projectDoSchema.projectWebhooks.createdAt));
251 const webhookDeliveryRows = await db
252 .select()
253 .from(projectDoSchema.projectWebhookDeliveries)
254 .orderBy(
255 asc(projectDoSchema.projectWebhookDeliveries.receivedAt),
256 asc(projectDoSchema.projectWebhookDeliveries.id),
257 );
258 
259 return {
260 state: stateRows[0] ?? null,
261 config: configRows[0] ?? null,
262 runs: runRows,
263 webhooks: webhookRows,
264 webhookDeliveries: webhookDeliveryRows,
265 };
266 });
267 
268export const drainProjectDoAlarms = async (): Promise<void> => {
269 const ids = await listDurableObjectIds(env.PROJECT_DO);
270 
271 for (const id of ids) {
272 const stub = env.PROJECT_DO.get(id);
273 await runInDurableObject(stub, async (instance: ProjectDO) => {
274 const { ctx: state } = instance as unknown as ProjectDoStateAccessor;
275 await state.storage.deleteAlarm();
276 });
277 }
278};