Skip to content
File

Blob: tests/helpers/project-do.ts

typescript224 lines
1import { runInDurableObject } from "cloudflare:test";
2import { env } from "cloudflare:workers";
3 
4import { BranchName, DispatchMode, ExecutionRuntime } from "@/contracts";
5import { ProjectDO } from "@/worker/durable";
6import {
7 acceptManualRun as acceptManualRunCommand,
8 claimRunWork as claimRunWorkCommand,
9 finalizeRunExecution as finalizeRunExecutionCommand,
10 requestRunCancel as requestRunCancelCommand,
11} from "@/worker/durable/project-do/commands";
12import { getProjectConfigRow } from "@/worker/durable/project-do/repo";
13import { transitionAcceptManualRun } from "@/worker/durable/project-do/transitions";
14import { expectTrusted } from "@/worker/contracts";
15import type { ProjectDoContext } from "@/worker/durable/project-do/types";
16import { createLogger } from "@/worker/services/logger";
17 
18type ProjectDoInternals = Pick<ProjectDoContext, "ctx" | "env" | "db">;
19 
20export const getProjectDoInternals = (instance: ProjectDO): ProjectDoInternals =>
21 instance as unknown as ProjectDoInternals;
22 
23export const createTestProjectDoContext = (instance: ProjectDO, envOverride?: Env): ProjectDoContext => {
24 const { ctx, env, db } = getProjectDoInternals(instance);
25 
26 return {
27 ctx,
28 env: envOverride ?? env,
29 db,
30 logger: createLogger("test.project-do"),
31 cacheProjectId: (projectId) => {
32 (instance as unknown as { cachedProjectId?: string | null }).cachedProjectId = projectId;
33 },
34 };
35};
36 
37export const createBoundStorageProxy = (
38 storage: DurableObjectStorage,
39 overrides: Partial<Pick<DurableObjectStorage, "getAlarm" | "setAlarm" | "transaction">>,
40): DurableObjectStorage =>
41 new Proxy(storage, {
42 get(target, prop, receiver) {
43 if (Object.prototype.hasOwnProperty.call(overrides, prop)) {
44 return overrides[prop as keyof typeof overrides];
45 }
46 
47 const value = Reflect.get(target, prop, receiver);
48 return typeof value === "function" ? value.bind(target) : value;
49 },
50 });
51 
52export const createBoundTransactionProxy = (
53 txn: DurableObjectTransaction,
54 overrides: Partial<Pick<DurableObjectTransaction, "getAlarm" | "setAlarm">>,
55): DurableObjectTransaction =>
56 new Proxy(txn, {
57 get(target, prop, receiver) {
58 if (Object.prototype.hasOwnProperty.call(overrides, prop)) {
59 return overrides[prop as keyof typeof overrides];
60 }
61 
62 const value = Reflect.get(target, prop, receiver);
63 return typeof value === "function" ? value.bind(target) : value;
64 },
65 });
66 
67export const withPatchedStorage = (context: ProjectDoContext, storage: DurableObjectStorage): ProjectDoContext => ({
68 ...context,
69 ctx: Object.assign(Object.create(context.ctx), { storage }) as DurableObjectState,
70});
71 
72export const createAlarmSchedulingSuppressedContext = (context: ProjectDoContext): ProjectDoContext => {
73 const transaction: DurableObjectStorage["transaction"] = async (closure) =>
74 context.ctx.storage.transaction(
75 async (txn) => await closure(createBoundTransactionProxy(txn, { setAlarm: async () => {} })),
76 );
77 const storage = createBoundStorageProxy(context.ctx.storage, { transaction });
78 
79 return withPatchedStorage(context, storage);
80};
81 
82export const createAlarmWriteFailingContext = (
83 context: ProjectDoContext,
84 errorMessage = "alarm write failed",
85): ProjectDoContext => {
86 const transaction: DurableObjectStorage["transaction"] = async (closure) =>
87 context.ctx.storage.transaction(async (txn) => {
88 const failingTxn = createBoundTransactionProxy(txn, {
89 setAlarm: async () => {
90 throw new Error(errorMessage);
91 },
92 });
93 
94 return await closure(failingTxn);
95 });
96 const storage = createBoundStorageProxy(context.ctx.storage, { transaction });
97 
98 return withPatchedStorage(context, storage);
99};
100 
101export const expectAcceptedManualRun = (
102 result: Awaited<ReturnType<typeof acceptManualRunCommand>>,
103): Extract<Awaited<ReturnType<typeof acceptManualRunCommand>>, { kind: "accepted" }> => {
104 if (result.kind !== "accepted") {
105 throw new Error(`Expected accepted manual run, got ${result.kind}:${result.reason}.`);
106 }
107 
108 return result;
109};
110 
111export const acceptManualRunWithoutAlarm = async (
112 projectStub: ReturnType<typeof env.PROJECT_DO.getByName>,
113 input: Parameters<typeof acceptManualRunCommand>[1],
114): Promise<Extract<Awaited<ReturnType<typeof acceptManualRunCommand>>, { kind: "accepted" }>> => {
115 let accepted: Awaited<ReturnType<typeof acceptManualRunCommand>> | null = null;
116 
117 await runInDurableObject(projectStub, async (instance: ProjectDO) => {
118 const context = createAlarmSchedulingSuppressedContext(createTestProjectDoContext(instance));
119 accepted = await acceptManualRunCommand(context, input);
120 });
121 
122 if (!accepted) {
123 throw new Error("acceptManualRunWithoutAlarm did not return an accepted run.");
124 }
125 
126 return expectAcceptedManualRun(accepted);
127};
128 
129export const fillManualRunQueueWithoutAlarmOrRunInitialization = async (
130 projectStub: ReturnType<typeof env.PROJECT_DO.getByName>,
131 input: Parameters<typeof acceptManualRunCommand>[1],
132 count: number,
133): Promise<void> => {
134 await runInDurableObject(projectStub, async (instance: ProjectDO) => {
135 const context = createAlarmSchedulingSuppressedContext(createTestProjectDoContext(instance));
136 
137 context.db.transaction((tx) => {
138 const projectConfigRow = getProjectConfigRow(tx, input.projectId);
139 if (!projectConfigRow) {
140 throw new Error(`Project config ${input.projectId} is missing during test queue fill.`);
141 }
142 
143 // Queue saturation tests only need ProjectDO rows. Skipping eager RunDO
144 // initialization removes a large amount of setup overhead on busy CI.
145 const resolvedInput = {
146 projectId: input.projectId,
147 triggeredByUserId: input.triggeredByUserId,
148 branch: input.branch ?? expectTrusted(BranchName, projectConfigRow.defaultBranch, "BranchName"),
149 repoUrl: projectConfigRow.repoUrl,
150 configPath: projectConfigRow.configPath,
151 dispatchMode: expectTrusted(DispatchMode, projectConfigRow.dispatchMode, "DispatchMode"),
152 executionRuntime: expectTrusted(ExecutionRuntime, projectConfigRow.executionRuntime, "ExecutionRuntime"),
153 };
154 const now = Date.now();
155 
156 for (let index = 0; index < count; index += 1) {
157 const accepted = transitionAcceptManualRun(context, tx, resolvedInput, now + index);
158 if (accepted.kind !== "accepted") {
159 throw new Error(`Expected accepted manual run during test queue fill, got ${accepted.reason}.`);
160 }
161 }
162 });
163 });
164};
165 
166export const claimRunWorkWithoutAlarm = async (
167 projectStub: ReturnType<typeof env.PROJECT_DO.getByName>,
168 input: Parameters<typeof claimRunWorkCommand>[1],
169): Promise<Awaited<ReturnType<typeof claimRunWorkCommand>>> => {
170 let claim: Awaited<ReturnType<typeof claimRunWorkCommand>> | null = null;
171 
172 await runInDurableObject(projectStub, async (instance: ProjectDO) => {
173 const context = createAlarmSchedulingSuppressedContext(createTestProjectDoContext(instance));
174 claim = await claimRunWorkCommand(context, input);
175 });
176 
177 if (!claim) {
178 throw new Error("claimRunWorkWithoutAlarm did not return a claim result.");
179 }
180 
181 return claim;
182};
183 
184export const finalizeRunExecutionWithoutAlarm = async (
185 projectStub: ReturnType<typeof env.PROJECT_DO.getByName>,
186 input: Omit<Parameters<typeof finalizeRunExecutionCommand>[1], "sandboxDestroyed"> & {
187 sandboxDestroyed?: boolean;
188 },
189): Promise<Awaited<ReturnType<typeof finalizeRunExecutionCommand>>> => {
190 let result: Awaited<ReturnType<typeof finalizeRunExecutionCommand>> | null = null;
191 
192 await runInDurableObject(projectStub, async (instance: ProjectDO) => {
193 const context = createAlarmSchedulingSuppressedContext(createTestProjectDoContext(instance));
194 result = await finalizeRunExecutionCommand(context, {
195 ...input,
196 sandboxDestroyed: input.sandboxDestroyed ?? true,
197 });
198 });
199 
200 if (!result) {
201 throw new Error("finalizeRunExecutionWithoutAlarm did not return a finalize result.");
202 }
203 
204 return result;
205};
206 
207export const requestRunCancelWithoutAlarm = async (
208 projectStub: ReturnType<typeof env.PROJECT_DO.getByName>,
209 input: Parameters<typeof requestRunCancelCommand>[1],
210): Promise<Awaited<ReturnType<typeof requestRunCancelCommand>>> => {
211 let result: Awaited<ReturnType<typeof requestRunCancelCommand>> | null = null;
212 
213 await runInDurableObject(projectStub, async (instance: ProjectDO) => {
214 const context = createAlarmSchedulingSuppressedContext(createTestProjectDoContext(instance));
215 result = await requestRunCancelCommand(context, input);
216 });
217 
218 if (!result) {
219 throw new Error("requestRunCancelWithoutAlarm did not return a cancel result.");
220 }
221 
222 return result;
223};