@yolk-sdk/vercel-workflows 0.1.0-canary.63 → 0.1.0-canary.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/testing/index.d.mts +96 -0
- package/dist/testing/index.d.mts.map +1 -0
- package/dist/testing/index.mjs +285 -0
- package/dist/testing/index.mjs.map +1 -0
- package/package.json +6 -1
- package/src/testing/index.ts +482 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { VercelWorkflowReadableOptions, VercelWorkflowReadableStream, VercelWorkflowRunStatus, VercelWorkflowsSdkClient } from "../effect.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/testing/index.d.ts
|
|
4
|
+
type TestWorkflowRunStatus = VercelWorkflowRunStatus;
|
|
5
|
+
declare class TestWorkflowStreamConflictError extends Error {
|
|
6
|
+
readonly name = "EntityConflictError";
|
|
7
|
+
readonly status = 409;
|
|
8
|
+
constructor(runId: string);
|
|
9
|
+
}
|
|
10
|
+
declare class TestWorkflowRunConflictError extends Error {
|
|
11
|
+
readonly name = "EntityConflictError";
|
|
12
|
+
readonly status = 409;
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
15
|
+
declare class TestWorkflowHookNotFoundError extends Error {
|
|
16
|
+
readonly name = "HookNotFoundError";
|
|
17
|
+
constructor(token: string);
|
|
18
|
+
}
|
|
19
|
+
declare class TestWorkflowHookConflictError extends Error {
|
|
20
|
+
readonly name = "HookConflictError";
|
|
21
|
+
constructor(token: string);
|
|
22
|
+
}
|
|
23
|
+
declare class TestWorkflowRunNotFoundError extends Error {
|
|
24
|
+
readonly name = "WorkflowRunNotFoundError";
|
|
25
|
+
constructor(runId: string);
|
|
26
|
+
}
|
|
27
|
+
declare class TestWorkflowRunCancelledError extends Error {
|
|
28
|
+
readonly name = "WorkflowRunCancelledError";
|
|
29
|
+
constructor(runId: string);
|
|
30
|
+
}
|
|
31
|
+
type TestWorkflowMetadata = {
|
|
32
|
+
readonly workflowRunId: string;
|
|
33
|
+
};
|
|
34
|
+
type TestWorkflowStepMetadata = {
|
|
35
|
+
readonly attempt: number;
|
|
36
|
+
readonly stepId: string;
|
|
37
|
+
readonly stepName: string;
|
|
38
|
+
};
|
|
39
|
+
type TestWorkflowStepFunction<TArgs extends ReadonlyArray<unknown>, TResult> = ((...args: TArgs) => Promise<TResult>) & {
|
|
40
|
+
readonly maxRetries?: number;
|
|
41
|
+
};
|
|
42
|
+
declare const defaultTestWorkflowStepMaxRetries = 3;
|
|
43
|
+
type TestWorkflowRunInspection = {
|
|
44
|
+
readonly runId: string;
|
|
45
|
+
readonly status: TestWorkflowRunStatus;
|
|
46
|
+
readonly chunks: ReadonlyArray<unknown>;
|
|
47
|
+
readonly streamClosed: boolean;
|
|
48
|
+
readonly streamCloseCount: number;
|
|
49
|
+
readonly writeAfterCloseAttempts: number;
|
|
50
|
+
readonly stepAttempts: ReadonlyMap<string, number>;
|
|
51
|
+
readonly runError: unknown;
|
|
52
|
+
};
|
|
53
|
+
type TestWorkflowHook<T> = Promise<T> & {
|
|
54
|
+
readonly [Symbol.dispose]: () => void;
|
|
55
|
+
};
|
|
56
|
+
declare class TestWorkflowWorld {
|
|
57
|
+
private readonly runs;
|
|
58
|
+
private runSequence;
|
|
59
|
+
start<TArgs extends ReadonlyArray<unknown>, TResult>(workflowFn: (...args: TArgs) => Promise<TResult>, args: TArgs): {
|
|
60
|
+
readonly runId: string;
|
|
61
|
+
};
|
|
62
|
+
settled(runId: string): Promise<void>;
|
|
63
|
+
inspect(runId: string): TestWorkflowRunInspection;
|
|
64
|
+
status(runId: string): TestWorkflowRunStatus;
|
|
65
|
+
cancel(runId: string): Promise<void>;
|
|
66
|
+
resumeHook(token: string, payload: unknown): Promise<void>;
|
|
67
|
+
hasResumedHook(token: string): boolean;
|
|
68
|
+
runStep<TArgs extends ReadonlyArray<unknown>, TResult>(stepFn: TestWorkflowStepFunction<TArgs, TResult>, args: TArgs, stepName?: string): Promise<TResult>;
|
|
69
|
+
step<TArgs extends ReadonlyArray<unknown>, TResult>(stepFn: TestWorkflowStepFunction<TArgs, TResult>, stepName?: string): (...args: TArgs) => Promise<TResult>;
|
|
70
|
+
static getWorkflowMetadata(): TestWorkflowMetadata;
|
|
71
|
+
static getStepMetadata(): TestWorkflowStepMetadata;
|
|
72
|
+
static getWritable<T>(): WritableStream<T>;
|
|
73
|
+
static createHook<T>(input: {
|
|
74
|
+
readonly token: string;
|
|
75
|
+
}): TestWorkflowHook<T>;
|
|
76
|
+
private makeWritable;
|
|
77
|
+
private makeHook;
|
|
78
|
+
getReadable<T>(runId: string, options?: VercelWorkflowReadableOptions): VercelWorkflowReadableStream<T>;
|
|
79
|
+
private streamEnded;
|
|
80
|
+
private notifyChange;
|
|
81
|
+
private nextChange;
|
|
82
|
+
private requireRun;
|
|
83
|
+
private sdkRun;
|
|
84
|
+
readonly sdk: VercelWorkflowsSdkClient;
|
|
85
|
+
}
|
|
86
|
+
declare const testWorkflowModule: {
|
|
87
|
+
getWorkflowMetadata: () => TestWorkflowMetadata;
|
|
88
|
+
getStepMetadata: () => TestWorkflowStepMetadata;
|
|
89
|
+
getWritable: <T>() => WritableStream<T>;
|
|
90
|
+
createHook: <T>(input: {
|
|
91
|
+
readonly token: string;
|
|
92
|
+
}) => TestWorkflowHook<T>;
|
|
93
|
+
};
|
|
94
|
+
//#endregion
|
|
95
|
+
export { TestWorkflowHook, TestWorkflowHookConflictError, TestWorkflowHookNotFoundError, TestWorkflowMetadata, TestWorkflowRunCancelledError, TestWorkflowRunConflictError, TestWorkflowRunInspection, TestWorkflowRunNotFoundError, TestWorkflowRunStatus, TestWorkflowStepFunction, TestWorkflowStepMetadata, TestWorkflowStreamConflictError, TestWorkflowWorld, defaultTestWorkflowStepMaxRetries, testWorkflowModule };
|
|
96
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/testing/index.ts"],"mappings":";;;KAsBY,qBAAA,GAAwB,uBAAuB;AAAA,cAK9C,+BAAA,SAAwC,KAAK;EAAA,SACtC,IAAA;EAAA,SACT,MAAA;cAEG,KAAA;AAAA;AAAA,cAKD,4BAAA,SAAqC,KAAK;EAAA,SACnC,IAAA;EAAA,SACT,MAAA;cAEG,OAAA;AAAA;AAAA,cAKD,6BAAA,SAAsC,KAAK;EAAA,SACpC,IAAA;cAEN,KAAA;AAAA;AAAA,cAKD,6BAAA,SAAsC,KAAK;EAAA,SACpC,IAAA;cAEN,KAAA;AAAA;AAAA,cAKD,4BAAA,SAAqC,KAAK;EAAA,SACnC,IAAA;cAEN,KAAA;AAAA;AAAA,cAKD,6BAAA,SAAsC,KAAK;EAAA,SACpC,IAAA;cAEN,KAAA;AAAA;AAAA,KAKF,oBAAA;EAAA,SAAkC,aAAa;AAAA;AAAA,KAE/C,wBAAA;EAAA,SACD,OAAA;EAAA,SACA,MAAA;EAAA,SACA,QAAA;AAAA;AAAA,KAGC,wBAAA,eAAuC,aAAA,2BAC9C,IAAA,EAAM,KAAA,KACN,OAAA,CAAQ,OAAA;EAAA,SAAuB,UAAA;AAAA;AAAA,cAGvB,iCAAA;AAAA,KA0CD,yBAAA;EAAA,SACD,KAAA;EAAA,SACA,MAAA,EAAQ,qBAAA;EAAA,SACR,MAAA,EAAQ,aAAA;EAAA,SACR,YAAA;EAAA,SACA,gBAAA;EAAA,SACA,uBAAA;EAAA,SACA,YAAA,EAAc,WAAA;EAAA,SACd,QAAA;AAAA;AAAA,KAGC,gBAAA,MAAsB,OAAA,CAAQ,CAAA;EAAA,UAAiB,MAAA,CAAO,OAAA;AAAA;AAAA,cAErD,iBAAA;EAAA,iBACM,IAAA;EAAA,QACT,WAAA;EAER,KAAA,eAAoB,aAAA,mBAAA,CAClB,UAAA,MAAgB,IAAA,EAAM,KAAA,KAAU,OAAA,CAAQ,OAAA,GACxC,IAAA,EAAM,KAAA;IAAA,SACM,KAAA;EAAA;EA6CR,OAAA,CAAQ,KAAA,WAAgB,OAAA;EAI9B,OAAA,CAAQ,KAAA,WAAgB,yBAAA;EAexB,MAAA,CAAO,KAAA,WAAgB,qBAAA;EAIjB,MAAA,CAAO,KAAA,WAAgB,OAAA;EAiBvB,UAAA,CAAW,KAAA,UAAe,OAAA,YAAmB,OAAA;EAkBnD,cAAA,CAAe,KAAA;EAaT,OAAA,eAAsB,aAAA,mBAAA,CAC1B,MAAA,EAAQ,wBAAA,CAAyB,KAAA,EAAO,OAAA,GACxC,IAAA,EAAM,KAAA,EACN,QAAA,YACC,OAAA,CAAQ,OAAA;EA0BX,IAAA,eAAmB,aAAA,mBAAA,CACjB,MAAA,EAAQ,wBAAA,CAAyB,KAAA,EAAO,OAAA,GACxC,QAAA,gBACK,IAAA,EAAM,KAAA,KAAU,OAAA,CAAQ,OAAA;EAAA,OAMxB,mBAAA,CAAA,GAAuB,oBAAA;EAAA,OAIvB,eAAA,CAAA,GAAmB,wBAAA;EAAA,OAUnB,WAAA,GAAA,CAAA,GAAkB,cAAA,CAAe,CAAA;EAAA,OAMjC,UAAA,GAAA,CAAc,KAAA;IAAA,SAAkB,KAAA;EAAA,IAAkB,gBAAA,CAAiB,CAAA;EAAA,QAQlE,YAAA;EAAA,QAwBA,QAAA;EAmBR,WAAA,GAAA,CACE,KAAA,UACA,OAAA,GAAU,6BAAA,GACT,4BAAA,CAA6B,CAAA;EAAA,QA+BxB,WAAA;EAAA,QAIA,YAAA;EAAA,QAMA,UAAA;EAAA,QAMA,UAAA;EAAA,QAUA,MAAA;EAAA,SAwBC,GAAA,EAAK,wBAAA;AAAA;AAAA,cAcH,kBAAA;;;wBAGI,cAAA,CAAA,CAAA;kBACD,KAAA;IAAA,SAAoB,KAAA;EAAA,MAAe,gBAAA,CAAA,CAAA;AAAA"}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
//#region src/testing/index.ts
|
|
3
|
+
var TestWorkflowStreamConflictError = class extends Error {
|
|
4
|
+
name = "EntityConflictError";
|
|
5
|
+
status = 409;
|
|
6
|
+
constructor(runId) {
|
|
7
|
+
super(`Workflow run "${runId}" stream already completed`);
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var TestWorkflowRunConflictError = class extends Error {
|
|
11
|
+
name = "EntityConflictError";
|
|
12
|
+
status = 409;
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var TestWorkflowHookNotFoundError = class extends Error {
|
|
18
|
+
name = "HookNotFoundError";
|
|
19
|
+
constructor(token) {
|
|
20
|
+
super(`Workflow hook "${token}" was not found`);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var TestWorkflowHookConflictError = class extends Error {
|
|
24
|
+
name = "HookConflictError";
|
|
25
|
+
constructor(token) {
|
|
26
|
+
super(`Workflow hook "${token}" was already resumed`);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var TestWorkflowRunNotFoundError = class extends Error {
|
|
30
|
+
name = "WorkflowRunNotFoundError";
|
|
31
|
+
constructor(runId) {
|
|
32
|
+
super(`Workflow run "${runId}" was not found`);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var TestWorkflowRunCancelledError = class extends Error {
|
|
36
|
+
name = "WorkflowRunCancelledError";
|
|
37
|
+
constructor(runId) {
|
|
38
|
+
super(`Workflow run "${runId}" was cancelled`);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const defaultTestWorkflowStepMaxRetries = 3;
|
|
42
|
+
const ambientStorage = new AsyncLocalStorage();
|
|
43
|
+
const requireAmbient = () => {
|
|
44
|
+
const ambient = ambientStorage.getStore();
|
|
45
|
+
if (ambient === void 0) throw new Error("Workflow ambient API called outside a TestWorkflowWorld run context");
|
|
46
|
+
return ambient;
|
|
47
|
+
};
|
|
48
|
+
var TestWorkflowWorld = class {
|
|
49
|
+
runs = /* @__PURE__ */ new Map();
|
|
50
|
+
runSequence = 0;
|
|
51
|
+
start(workflowFn, args) {
|
|
52
|
+
this.runSequence += 1;
|
|
53
|
+
const runId = `twr_${this.runSequence}`;
|
|
54
|
+
const run = {
|
|
55
|
+
runId,
|
|
56
|
+
status: "pending",
|
|
57
|
+
returnValue: void 0,
|
|
58
|
+
runError: void 0,
|
|
59
|
+
settled: Promise.resolve(),
|
|
60
|
+
chunks: [],
|
|
61
|
+
streamClosed: false,
|
|
62
|
+
streamCloseCount: 0,
|
|
63
|
+
writeAfterCloseAttempts: 0,
|
|
64
|
+
changeListeners: /* @__PURE__ */ new Set(),
|
|
65
|
+
hooks: /* @__PURE__ */ new Map(),
|
|
66
|
+
resumedHookTokens: /* @__PURE__ */ new Set(),
|
|
67
|
+
stepSequence: 0,
|
|
68
|
+
stepAttempts: /* @__PURE__ */ new Map()
|
|
69
|
+
};
|
|
70
|
+
this.runs.set(runId, run);
|
|
71
|
+
run.settled = ambientStorage.run({
|
|
72
|
+
world: this,
|
|
73
|
+
run,
|
|
74
|
+
stepMetadata: void 0
|
|
75
|
+
}, async () => {
|
|
76
|
+
await Promise.resolve();
|
|
77
|
+
if (run.status === "cancelled") return;
|
|
78
|
+
run.status = "running";
|
|
79
|
+
try {
|
|
80
|
+
run.returnValue = await workflowFn(...args);
|
|
81
|
+
if (run.status === "running") run.status = "completed";
|
|
82
|
+
} catch (error) {
|
|
83
|
+
run.runError = error;
|
|
84
|
+
if (run.status === "running") run.status = "failed";
|
|
85
|
+
} finally {
|
|
86
|
+
this.notifyChange(run);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return { runId };
|
|
90
|
+
}
|
|
91
|
+
async settled(runId) {
|
|
92
|
+
await this.requireRun(runId).settled;
|
|
93
|
+
}
|
|
94
|
+
inspect(runId) {
|
|
95
|
+
const run = this.requireRun(runId);
|
|
96
|
+
return {
|
|
97
|
+
runId: run.runId,
|
|
98
|
+
status: run.status,
|
|
99
|
+
chunks: [...run.chunks],
|
|
100
|
+
streamClosed: run.streamClosed,
|
|
101
|
+
streamCloseCount: run.streamCloseCount,
|
|
102
|
+
writeAfterCloseAttempts: run.writeAfterCloseAttempts,
|
|
103
|
+
stepAttempts: new Map(run.stepAttempts),
|
|
104
|
+
runError: run.runError
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
status(runId) {
|
|
108
|
+
return this.requireRun(runId).status;
|
|
109
|
+
}
|
|
110
|
+
async cancel(runId) {
|
|
111
|
+
const run = this.requireRun(runId);
|
|
112
|
+
if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") throw new TestWorkflowRunConflictError(`Workflow run "${runId}" is already ${run.status} and cannot be cancelled`);
|
|
113
|
+
run.status = "cancelled";
|
|
114
|
+
for (const [token, hook] of run.hooks) {
|
|
115
|
+
run.hooks.delete(token);
|
|
116
|
+
hook.reject(new TestWorkflowRunCancelledError(runId));
|
|
117
|
+
}
|
|
118
|
+
this.notifyChange(run);
|
|
119
|
+
}
|
|
120
|
+
async resumeHook(token, payload) {
|
|
121
|
+
for (const run of this.runs.values()) {
|
|
122
|
+
const hook = run.hooks.get(token);
|
|
123
|
+
if (hook !== void 0) {
|
|
124
|
+
run.hooks.delete(token);
|
|
125
|
+
run.resumedHookTokens.add(token);
|
|
126
|
+
hook.resolve(payload);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (run.resumedHookTokens.has(token)) throw new TestWorkflowHookConflictError(token);
|
|
130
|
+
}
|
|
131
|
+
throw new TestWorkflowHookNotFoundError(token);
|
|
132
|
+
}
|
|
133
|
+
hasResumedHook(token) {
|
|
134
|
+
for (const run of this.runs.values()) if (run.resumedHookTokens.has(token)) return true;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
async runStep(stepFn, args, stepName = stepFn.name === "" ? "step" : stepFn.name) {
|
|
138
|
+
const run = requireAmbient().run;
|
|
139
|
+
run.stepSequence += 1;
|
|
140
|
+
const stepId = `${run.runId}:step:${run.stepSequence}:${stepName}`;
|
|
141
|
+
const maxAttempts = (stepFn.maxRetries ?? 3) + 1;
|
|
142
|
+
let attempt = 1;
|
|
143
|
+
for (;;) {
|
|
144
|
+
run.stepAttempts.set(stepName, (run.stepAttempts.get(stepName) ?? 0) + 1);
|
|
145
|
+
try {
|
|
146
|
+
return await ambientStorage.run({
|
|
147
|
+
world: this,
|
|
148
|
+
run,
|
|
149
|
+
stepMetadata: {
|
|
150
|
+
attempt,
|
|
151
|
+
stepId,
|
|
152
|
+
stepName
|
|
153
|
+
}
|
|
154
|
+
}, () => stepFn(...args));
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (attempt >= maxAttempts) throw error;
|
|
157
|
+
attempt += 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
step(stepFn, stepName) {
|
|
162
|
+
return (...args) => this.runStep(stepFn, args, stepName);
|
|
163
|
+
}
|
|
164
|
+
static getWorkflowMetadata() {
|
|
165
|
+
return { workflowRunId: requireAmbient().run.runId };
|
|
166
|
+
}
|
|
167
|
+
static getStepMetadata() {
|
|
168
|
+
const ambient = requireAmbient();
|
|
169
|
+
if (ambient.stepMetadata === void 0) throw new Error("getStepMetadata called outside a step context");
|
|
170
|
+
return ambient.stepMetadata;
|
|
171
|
+
}
|
|
172
|
+
static getWritable() {
|
|
173
|
+
const ambient = requireAmbient();
|
|
174
|
+
return ambient.world.makeWritable(ambient.run);
|
|
175
|
+
}
|
|
176
|
+
static createHook(input) {
|
|
177
|
+
const ambient = requireAmbient();
|
|
178
|
+
return ambient.world.makeHook(ambient.run, input.token);
|
|
179
|
+
}
|
|
180
|
+
makeWritable(run) {
|
|
181
|
+
return new WritableStream({
|
|
182
|
+
write: (chunk) => {
|
|
183
|
+
if (run.streamClosed || run.status === "cancelled") {
|
|
184
|
+
run.writeAfterCloseAttempts += 1;
|
|
185
|
+
throw new TestWorkflowStreamConflictError(run.runId);
|
|
186
|
+
}
|
|
187
|
+
run.chunks.push(chunk);
|
|
188
|
+
this.notifyChange(run);
|
|
189
|
+
},
|
|
190
|
+
close: () => {
|
|
191
|
+
if (run.streamClosed) {
|
|
192
|
+
run.streamCloseCount += 1;
|
|
193
|
+
throw new TestWorkflowStreamConflictError(run.runId);
|
|
194
|
+
}
|
|
195
|
+
run.streamClosed = true;
|
|
196
|
+
run.streamCloseCount += 1;
|
|
197
|
+
this.notifyChange(run);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
makeHook(run, token) {
|
|
202
|
+
let resolveHook = () => {};
|
|
203
|
+
let rejectHook = () => {};
|
|
204
|
+
const promise = new Promise((resolve, reject) => {
|
|
205
|
+
resolveHook = (payload) => resolve(payload);
|
|
206
|
+
rejectHook = reject;
|
|
207
|
+
});
|
|
208
|
+
run.hooks.set(token, {
|
|
209
|
+
resolve: resolveHook,
|
|
210
|
+
reject: rejectHook
|
|
211
|
+
});
|
|
212
|
+
return Object.assign(promise, { [Symbol.dispose]: () => {
|
|
213
|
+
run.hooks.delete(token);
|
|
214
|
+
} });
|
|
215
|
+
}
|
|
216
|
+
getReadable(runId, options) {
|
|
217
|
+
const run = this.requireRun(runId);
|
|
218
|
+
const requestedStart = options?.startIndex ?? 0;
|
|
219
|
+
let index = requestedStart < 0 ? Math.max(0, run.chunks.length + requestedStart) : requestedStart;
|
|
220
|
+
const stream = new ReadableStream({ pull: async (controller) => {
|
|
221
|
+
while (index >= run.chunks.length && !this.streamEnded(run)) await this.nextChange(run);
|
|
222
|
+
if (index < run.chunks.length) {
|
|
223
|
+
controller.enqueue(run.chunks[index]);
|
|
224
|
+
index += 1;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
controller.close();
|
|
228
|
+
} });
|
|
229
|
+
return Object.assign(stream, { getTailIndex: () => Promise.resolve(run.chunks.length - 1) });
|
|
230
|
+
}
|
|
231
|
+
streamEnded(run) {
|
|
232
|
+
return run.streamClosed || run.status === "cancelled";
|
|
233
|
+
}
|
|
234
|
+
notifyChange(run) {
|
|
235
|
+
const listeners = [...run.changeListeners];
|
|
236
|
+
run.changeListeners.clear();
|
|
237
|
+
for (const listener of listeners) listener();
|
|
238
|
+
}
|
|
239
|
+
nextChange(run) {
|
|
240
|
+
return new Promise((resolve) => {
|
|
241
|
+
run.changeListeners.add(resolve);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
requireRun(runId) {
|
|
245
|
+
const run = this.runs.get(runId);
|
|
246
|
+
if (run === void 0) throw new TestWorkflowRunNotFoundError(runId);
|
|
247
|
+
return run;
|
|
248
|
+
}
|
|
249
|
+
sdkRun(run) {
|
|
250
|
+
const getReadable = (options) => this.getReadable(run.runId, options);
|
|
251
|
+
const cancel = () => this.cancel(run.runId);
|
|
252
|
+
return {
|
|
253
|
+
runId: run.runId,
|
|
254
|
+
getReadable,
|
|
255
|
+
cancel,
|
|
256
|
+
get status() {
|
|
257
|
+
return Promise.resolve(run.status);
|
|
258
|
+
},
|
|
259
|
+
get returnValue() {
|
|
260
|
+
return run.settled.then(() => {
|
|
261
|
+
if (run.status === "completed") return run.returnValue;
|
|
262
|
+
throw run.runError ?? new TestWorkflowRunCancelledError(run.runId);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
sdk = {
|
|
268
|
+
start: async (workflowFn, args) => {
|
|
269
|
+
const { runId } = this.start(workflowFn, args);
|
|
270
|
+
return this.sdkRun(this.requireRun(runId));
|
|
271
|
+
},
|
|
272
|
+
getRun: (runId) => this.sdkRun(this.requireRun(runId)),
|
|
273
|
+
resumeHook: (token, payload) => this.resumeHook(token, payload)
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
const testWorkflowModule = {
|
|
277
|
+
getWorkflowMetadata: () => TestWorkflowWorld.getWorkflowMetadata(),
|
|
278
|
+
getStepMetadata: () => TestWorkflowWorld.getStepMetadata(),
|
|
279
|
+
getWritable: () => TestWorkflowWorld.getWritable(),
|
|
280
|
+
createHook: (input) => TestWorkflowWorld.createHook(input)
|
|
281
|
+
};
|
|
282
|
+
//#endregion
|
|
283
|
+
export { TestWorkflowHookConflictError, TestWorkflowHookNotFoundError, TestWorkflowRunCancelledError, TestWorkflowRunConflictError, TestWorkflowRunNotFoundError, TestWorkflowStreamConflictError, TestWorkflowWorld, defaultTestWorkflowStepMaxRetries, testWorkflowModule };
|
|
284
|
+
|
|
285
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/testing/index.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks'\nimport type {\n VercelWorkflowsSdkClient,\n VercelWorkflowsSdkRun,\n VercelWorkflowReadableOptions,\n VercelWorkflowReadableStream,\n VercelWorkflowRunStatus\n} from '../effect.ts'\n\n// Behavioral emulator of the Vercel Workflow platform for tests: run lifecycle,\n// append-only durable streams with close-once -> 409 write conflicts, a step\n// executor honoring `fn.maxRetries` + `getStepMetadata().attempt`, hooks/resume,\n// and cancellation. Host code under test runs unmodified against it; tests mock\n// only the `workflow` module's ambient APIs, delegating to the active world\n// (see `testWorkflowModule`).\n//\n// The single most important semantic: writing to (or re-closing) a closed\n// durable stream fails with the platform's HTTP 409 \"already completed\"\n// conflict shape. Any code path that performs a terminal stream effect from a\n// retryable step attempt poisons every subsequent platform retry — exactly the\n// #252 production bug. The emulator enforces that invariant permanently.\n\nexport type TestWorkflowRunStatus = VercelWorkflowRunStatus\n\n// Matches the platform's conflict contract: `EntityConflictError.is(value)`\n// checks `value.name === 'EntityConflictError'`, and the Vercel world maps\n// HTTP 409 responses to that class.\nexport class TestWorkflowStreamConflictError extends Error {\n override readonly name = 'EntityConflictError'\n readonly status = 409\n\n constructor(runId: string) {\n super(`Workflow run \"${runId}\" stream already completed`)\n }\n}\n\nexport class TestWorkflowRunConflictError extends Error {\n override readonly name = 'EntityConflictError'\n readonly status = 409\n\n constructor(message: string) {\n super(message)\n }\n}\n\nexport class TestWorkflowHookNotFoundError extends Error {\n override readonly name = 'HookNotFoundError'\n\n constructor(token: string) {\n super(`Workflow hook \"${token}\" was not found`)\n }\n}\n\nexport class TestWorkflowHookConflictError extends Error {\n override readonly name = 'HookConflictError'\n\n constructor(token: string) {\n super(`Workflow hook \"${token}\" was already resumed`)\n }\n}\n\nexport class TestWorkflowRunNotFoundError extends Error {\n override readonly name = 'WorkflowRunNotFoundError'\n\n constructor(runId: string) {\n super(`Workflow run \"${runId}\" was not found`)\n }\n}\n\nexport class TestWorkflowRunCancelledError extends Error {\n override readonly name = 'WorkflowRunCancelledError'\n\n constructor(runId: string) {\n super(`Workflow run \"${runId}\" was cancelled`)\n }\n}\n\nexport type TestWorkflowMetadata = { readonly workflowRunId: string }\n\nexport type TestWorkflowStepMetadata = {\n readonly attempt: number\n readonly stepId: string\n readonly stepName: string\n}\n\nexport type TestWorkflowStepFunction<TArgs extends ReadonlyArray<unknown>, TResult> = ((\n ...args: TArgs\n) => Promise<TResult>) & { readonly maxRetries?: number }\n\n// The platform default: retries after the first attempt; total attempts = maxRetries + 1.\nexport const defaultTestWorkflowStepMaxRetries = 3\n\ntype HookRecord = {\n readonly resolve: (payload: unknown) => void\n readonly reject: (error: unknown) => void\n}\n\ntype TestWorkflowRunRecord = {\n readonly runId: string\n status: TestWorkflowRunStatus\n returnValue: unknown\n runError: unknown\n settled: Promise<void>\n readonly chunks: Array<unknown>\n streamClosed: boolean\n streamCloseCount: number\n writeAfterCloseAttempts: number\n readonly changeListeners: Set<() => void>\n readonly hooks: Map<string, HookRecord>\n readonly resumedHookTokens: Set<string>\n stepSequence: number\n readonly stepAttempts: Map<string, number>\n}\n\ntype AmbientContext = {\n readonly world: TestWorkflowWorld\n readonly run: TestWorkflowRunRecord\n readonly stepMetadata: TestWorkflowStepMetadata | undefined\n}\n\nconst ambientStorage = new AsyncLocalStorage<AmbientContext>()\n\nconst requireAmbient = (): AmbientContext => {\n const ambient = ambientStorage.getStore()\n\n if (ambient === undefined) {\n throw new Error('Workflow ambient API called outside a TestWorkflowWorld run context')\n }\n\n return ambient\n}\n\nexport type TestWorkflowRunInspection = {\n readonly runId: string\n readonly status: TestWorkflowRunStatus\n readonly chunks: ReadonlyArray<unknown>\n readonly streamClosed: boolean\n readonly streamCloseCount: number\n readonly writeAfterCloseAttempts: number\n readonly stepAttempts: ReadonlyMap<string, number>\n readonly runError: unknown\n}\n\nexport type TestWorkflowHook<T> = Promise<T> & { readonly [Symbol.dispose]: () => void }\n\nexport class TestWorkflowWorld {\n private readonly runs = new Map<string, TestWorkflowRunRecord>()\n private runSequence = 0\n\n start<TArgs extends ReadonlyArray<unknown>, TResult>(\n workflowFn: (...args: TArgs) => Promise<TResult>,\n args: TArgs\n ): { readonly runId: string } {\n this.runSequence += 1\n const runId = `twr_${this.runSequence}`\n const run: TestWorkflowRunRecord = {\n runId,\n status: 'pending',\n returnValue: undefined,\n runError: undefined,\n settled: Promise.resolve(),\n chunks: [],\n streamClosed: false,\n streamCloseCount: 0,\n writeAfterCloseAttempts: 0,\n changeListeners: new Set(),\n hooks: new Map(),\n resumedHookTokens: new Set(),\n stepSequence: 0,\n stepAttempts: new Map()\n }\n this.runs.set(runId, run)\n\n run.settled = ambientStorage.run(\n { world: this, run, stepMetadata: undefined },\n async (): Promise<void> => {\n // Yield once so callers observe `pending` before execution begins,\n // mirroring the platform's enqueue-then-run lifecycle.\n await Promise.resolve()\n if (run.status === 'cancelled') return\n\n run.status = 'running'\n try {\n run.returnValue = await workflowFn(...args)\n if (run.status === 'running') run.status = 'completed'\n } catch (error) {\n run.runError = error\n if (run.status === 'running') run.status = 'failed'\n } finally {\n this.notifyChange(run)\n }\n }\n )\n\n return { runId }\n }\n\n async settled(runId: string): Promise<void> {\n await this.requireRun(runId).settled\n }\n\n inspect(runId: string): TestWorkflowRunInspection {\n const run = this.requireRun(runId)\n\n return {\n runId: run.runId,\n status: run.status,\n chunks: [...run.chunks],\n streamClosed: run.streamClosed,\n streamCloseCount: run.streamCloseCount,\n writeAfterCloseAttempts: run.writeAfterCloseAttempts,\n stepAttempts: new Map(run.stepAttempts),\n runError: run.runError\n }\n }\n\n status(runId: string): TestWorkflowRunStatus {\n return this.requireRun(runId).status\n }\n\n async cancel(runId: string): Promise<void> {\n const run = this.requireRun(runId)\n\n if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {\n throw new TestWorkflowRunConflictError(\n `Workflow run \"${runId}\" is already ${run.status} and cannot be cancelled`\n )\n }\n\n run.status = 'cancelled'\n for (const [token, hook] of run.hooks) {\n run.hooks.delete(token)\n hook.reject(new TestWorkflowRunCancelledError(runId))\n }\n this.notifyChange(run)\n }\n\n async resumeHook(token: string, payload: unknown): Promise<void> {\n for (const run of this.runs.values()) {\n const hook = run.hooks.get(token)\n if (hook !== undefined) {\n run.hooks.delete(token)\n run.resumedHookTokens.add(token)\n hook.resolve(payload)\n return\n }\n\n if (run.resumedHookTokens.has(token)) {\n throw new TestWorkflowHookConflictError(token)\n }\n }\n\n throw new TestWorkflowHookNotFoundError(token)\n }\n\n hasResumedHook(token: string): boolean {\n for (const run of this.runs.values()) {\n if (run.resumedHookTokens.has(token)) return true\n }\n\n return false\n }\n\n // --- Step executor (platform retry semantics) ---\n\n // Runs a step body with the platform's retry contract: `fn.maxRetries`\n // retries after the first attempt (total attempts = maxRetries + 1) and\n // 1-based `getStepMetadata().attempt` visible to the body.\n async runStep<TArgs extends ReadonlyArray<unknown>, TResult>(\n stepFn: TestWorkflowStepFunction<TArgs, TResult>,\n args: TArgs,\n stepName = stepFn.name === '' ? 'step' : stepFn.name\n ): Promise<TResult> {\n const ambient = requireAmbient()\n const run = ambient.run\n run.stepSequence += 1\n const stepId = `${run.runId}:step:${run.stepSequence}:${stepName}`\n const maxRetries = stepFn.maxRetries ?? defaultTestWorkflowStepMaxRetries\n const maxAttempts = maxRetries + 1\n\n let attempt = 1\n\n for (;;) {\n run.stepAttempts.set(stepName, (run.stepAttempts.get(stepName) ?? 0) + 1)\n try {\n return await ambientStorage.run(\n { world: this, run, stepMetadata: { attempt, stepId, stepName } },\n () => stepFn(...args)\n )\n } catch (error) {\n if (attempt >= maxAttempts) throw error\n attempt += 1\n }\n }\n }\n\n // Wraps a step function so orchestration code can invoke it exactly the way\n // the platform invokes `use step` functions: through the retry executor.\n step<TArgs extends ReadonlyArray<unknown>, TResult>(\n stepFn: TestWorkflowStepFunction<TArgs, TResult>,\n stepName?: string\n ): (...args: TArgs) => Promise<TResult> {\n return (...args: TArgs) => this.runStep(stepFn, args, stepName)\n }\n\n // --- Ambient platform APIs (exposed through `testWorkflowModule`) ---\n\n static getWorkflowMetadata(): TestWorkflowMetadata {\n return { workflowRunId: requireAmbient().run.runId }\n }\n\n static getStepMetadata(): TestWorkflowStepMetadata {\n const ambient = requireAmbient()\n\n if (ambient.stepMetadata === undefined) {\n throw new Error('getStepMetadata called outside a step context')\n }\n\n return ambient.stepMetadata\n }\n\n static getWritable<T>(): WritableStream<T> {\n const ambient = requireAmbient()\n\n return ambient.world.makeWritable(ambient.run)\n }\n\n static createHook<T>(input: { readonly token: string }): TestWorkflowHook<T> {\n const ambient = requireAmbient()\n\n return ambient.world.makeHook<T>(ambient.run, input.token)\n }\n\n // --- Durable streams ---\n\n private makeWritable<T>(run: TestWorkflowRunRecord): WritableStream<T> {\n return new WritableStream<T>({\n write: chunk => {\n if (run.streamClosed || run.status === 'cancelled') {\n run.writeAfterCloseAttempts += 1\n throw new TestWorkflowStreamConflictError(run.runId)\n }\n\n run.chunks.push(chunk)\n this.notifyChange(run)\n },\n close: () => {\n if (run.streamClosed) {\n run.streamCloseCount += 1\n throw new TestWorkflowStreamConflictError(run.runId)\n }\n\n run.streamClosed = true\n run.streamCloseCount += 1\n this.notifyChange(run)\n }\n })\n }\n\n private makeHook<T>(run: TestWorkflowRunRecord, token: string): TestWorkflowHook<T> {\n let resolveHook: (payload: unknown) => void = () => {}\n let rejectHook: (error: unknown) => void = () => {}\n const promise = new Promise<T>((resolve, reject) => {\n // Hook payloads are caller-typed on the platform (`createHook<T>`).\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n resolveHook = payload => resolve(payload as T)\n rejectHook = reject\n })\n\n run.hooks.set(token, { resolve: resolveHook, reject: rejectHook })\n\n return Object.assign(promise, {\n [Symbol.dispose]: () => {\n run.hooks.delete(token)\n }\n })\n }\n\n getReadable<T>(\n runId: string,\n options?: VercelWorkflowReadableOptions\n ): VercelWorkflowReadableStream<T> {\n const run = this.requireRun(runId)\n const requestedStart = options?.startIndex ?? 0\n let index =\n requestedStart < 0 ? Math.max(0, run.chunks.length + requestedStart) : requestedStart\n\n const stream = new ReadableStream<T>({\n pull: async controller => {\n while (index >= run.chunks.length && !this.streamEnded(run)) {\n await this.nextChange(run)\n }\n\n if (index < run.chunks.length) {\n // The platform readable is caller-typed (`WorkflowReadableStream<R = any>`);\n // the durable log stores opaque chunks, so this coercion mirrors the\n // platform contract exactly.\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n controller.enqueue(run.chunks[index] as T)\n index += 1\n return\n }\n\n controller.close()\n }\n })\n\n return Object.assign(stream, {\n getTailIndex: () => Promise.resolve(run.chunks.length - 1)\n })\n }\n\n private streamEnded(run: TestWorkflowRunRecord): boolean {\n return run.streamClosed || run.status === 'cancelled'\n }\n\n private notifyChange(run: TestWorkflowRunRecord): void {\n const listeners = [...run.changeListeners]\n run.changeListeners.clear()\n for (const listener of listeners) listener()\n }\n\n private nextChange(run: TestWorkflowRunRecord): Promise<void> {\n return new Promise(resolve => {\n run.changeListeners.add(resolve)\n })\n }\n\n private requireRun(runId: string): TestWorkflowRunRecord {\n const run = this.runs.get(runId)\n\n if (run === undefined) throw new TestWorkflowRunNotFoundError(runId)\n\n return run\n }\n\n // --- SDK adapter for `VercelWorkflows.layerFromSdk` ---\n\n private sdkRun<TResult>(run: TestWorkflowRunRecord): VercelWorkflowsSdkRun<TResult> {\n const getReadable = <Chunk>(options?: VercelWorkflowReadableOptions) =>\n this.getReadable<Chunk>(run.runId, options)\n const cancel = () => this.cancel(run.runId)\n\n return {\n runId: run.runId,\n getReadable,\n cancel,\n get status() {\n return Promise.resolve(run.status)\n },\n get returnValue() {\n return run.settled.then(() => {\n // Return values are caller-typed on the platform SDK as well.\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n if (run.status === 'completed') return run.returnValue as TResult\n\n throw run.runError ?? new TestWorkflowRunCancelledError(run.runId)\n })\n }\n }\n }\n\n readonly sdk: VercelWorkflowsSdkClient = {\n start: async (workflowFn, args) => {\n const { runId } = this.start(workflowFn, args)\n\n return this.sdkRun(this.requireRun(runId))\n },\n getRun: <TResult>(runId: string) => this.sdkRun<TResult>(this.requireRun(runId)),\n resumeHook: (token, payload) => this.resumeHook(token, payload)\n }\n}\n\n// Factory for `vi.mock('workflow', () => testWorkflowModule)`: the mocked\n// module surface delegates every ambient platform API to the active world's\n// AsyncLocalStorage context, so production code under test runs unmodified.\nexport const testWorkflowModule = {\n getWorkflowMetadata: () => TestWorkflowWorld.getWorkflowMetadata(),\n getStepMetadata: () => TestWorkflowWorld.getStepMetadata(),\n getWritable: <T>() => TestWorkflowWorld.getWritable<T>(),\n createHook: <T>(input: { readonly token: string }) => TestWorkflowWorld.createHook<T>(input)\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolk-sdk/vercel-workflows",
|
|
3
|
-
"version": "0.1.0-canary.
|
|
3
|
+
"version": "0.1.0-canary.64",
|
|
4
4
|
"description": "Vercel Workflow orchestration primitives for Yolk agent loops.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
"types": "./dist/effect.d.mts",
|
|
41
41
|
"import": "./dist/effect.mjs",
|
|
42
42
|
"default": "./dist/effect.mjs"
|
|
43
|
+
},
|
|
44
|
+
"./testing": {
|
|
45
|
+
"types": "./dist/testing/index.d.mts",
|
|
46
|
+
"import": "./dist/testing/index.mjs",
|
|
47
|
+
"default": "./dist/testing/index.mjs"
|
|
43
48
|
}
|
|
44
49
|
},
|
|
45
50
|
"files": [
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import type {
|
|
3
|
+
VercelWorkflowsSdkClient,
|
|
4
|
+
VercelWorkflowsSdkRun,
|
|
5
|
+
VercelWorkflowReadableOptions,
|
|
6
|
+
VercelWorkflowReadableStream,
|
|
7
|
+
VercelWorkflowRunStatus
|
|
8
|
+
} from '../effect.ts'
|
|
9
|
+
|
|
10
|
+
// Behavioral emulator of the Vercel Workflow platform for tests: run lifecycle,
|
|
11
|
+
// append-only durable streams with close-once -> 409 write conflicts, a step
|
|
12
|
+
// executor honoring `fn.maxRetries` + `getStepMetadata().attempt`, hooks/resume,
|
|
13
|
+
// and cancellation. Host code under test runs unmodified against it; tests mock
|
|
14
|
+
// only the `workflow` module's ambient APIs, delegating to the active world
|
|
15
|
+
// (see `testWorkflowModule`).
|
|
16
|
+
//
|
|
17
|
+
// The single most important semantic: writing to (or re-closing) a closed
|
|
18
|
+
// durable stream fails with the platform's HTTP 409 "already completed"
|
|
19
|
+
// conflict shape. Any code path that performs a terminal stream effect from a
|
|
20
|
+
// retryable step attempt poisons every subsequent platform retry — exactly the
|
|
21
|
+
// #252 production bug. The emulator enforces that invariant permanently.
|
|
22
|
+
|
|
23
|
+
export type TestWorkflowRunStatus = VercelWorkflowRunStatus
|
|
24
|
+
|
|
25
|
+
// Matches the platform's conflict contract: `EntityConflictError.is(value)`
|
|
26
|
+
// checks `value.name === 'EntityConflictError'`, and the Vercel world maps
|
|
27
|
+
// HTTP 409 responses to that class.
|
|
28
|
+
export class TestWorkflowStreamConflictError extends Error {
|
|
29
|
+
override readonly name = 'EntityConflictError'
|
|
30
|
+
readonly status = 409
|
|
31
|
+
|
|
32
|
+
constructor(runId: string) {
|
|
33
|
+
super(`Workflow run "${runId}" stream already completed`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class TestWorkflowRunConflictError extends Error {
|
|
38
|
+
override readonly name = 'EntityConflictError'
|
|
39
|
+
readonly status = 409
|
|
40
|
+
|
|
41
|
+
constructor(message: string) {
|
|
42
|
+
super(message)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class TestWorkflowHookNotFoundError extends Error {
|
|
47
|
+
override readonly name = 'HookNotFoundError'
|
|
48
|
+
|
|
49
|
+
constructor(token: string) {
|
|
50
|
+
super(`Workflow hook "${token}" was not found`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class TestWorkflowHookConflictError extends Error {
|
|
55
|
+
override readonly name = 'HookConflictError'
|
|
56
|
+
|
|
57
|
+
constructor(token: string) {
|
|
58
|
+
super(`Workflow hook "${token}" was already resumed`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class TestWorkflowRunNotFoundError extends Error {
|
|
63
|
+
override readonly name = 'WorkflowRunNotFoundError'
|
|
64
|
+
|
|
65
|
+
constructor(runId: string) {
|
|
66
|
+
super(`Workflow run "${runId}" was not found`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class TestWorkflowRunCancelledError extends Error {
|
|
71
|
+
override readonly name = 'WorkflowRunCancelledError'
|
|
72
|
+
|
|
73
|
+
constructor(runId: string) {
|
|
74
|
+
super(`Workflow run "${runId}" was cancelled`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type TestWorkflowMetadata = { readonly workflowRunId: string }
|
|
79
|
+
|
|
80
|
+
export type TestWorkflowStepMetadata = {
|
|
81
|
+
readonly attempt: number
|
|
82
|
+
readonly stepId: string
|
|
83
|
+
readonly stepName: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type TestWorkflowStepFunction<TArgs extends ReadonlyArray<unknown>, TResult> = ((
|
|
87
|
+
...args: TArgs
|
|
88
|
+
) => Promise<TResult>) & { readonly maxRetries?: number }
|
|
89
|
+
|
|
90
|
+
// The platform default: retries after the first attempt; total attempts = maxRetries + 1.
|
|
91
|
+
export const defaultTestWorkflowStepMaxRetries = 3
|
|
92
|
+
|
|
93
|
+
type HookRecord = {
|
|
94
|
+
readonly resolve: (payload: unknown) => void
|
|
95
|
+
readonly reject: (error: unknown) => void
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type TestWorkflowRunRecord = {
|
|
99
|
+
readonly runId: string
|
|
100
|
+
status: TestWorkflowRunStatus
|
|
101
|
+
returnValue: unknown
|
|
102
|
+
runError: unknown
|
|
103
|
+
settled: Promise<void>
|
|
104
|
+
readonly chunks: Array<unknown>
|
|
105
|
+
streamClosed: boolean
|
|
106
|
+
streamCloseCount: number
|
|
107
|
+
writeAfterCloseAttempts: number
|
|
108
|
+
readonly changeListeners: Set<() => void>
|
|
109
|
+
readonly hooks: Map<string, HookRecord>
|
|
110
|
+
readonly resumedHookTokens: Set<string>
|
|
111
|
+
stepSequence: number
|
|
112
|
+
readonly stepAttempts: Map<string, number>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type AmbientContext = {
|
|
116
|
+
readonly world: TestWorkflowWorld
|
|
117
|
+
readonly run: TestWorkflowRunRecord
|
|
118
|
+
readonly stepMetadata: TestWorkflowStepMetadata | undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ambientStorage = new AsyncLocalStorage<AmbientContext>()
|
|
122
|
+
|
|
123
|
+
const requireAmbient = (): AmbientContext => {
|
|
124
|
+
const ambient = ambientStorage.getStore()
|
|
125
|
+
|
|
126
|
+
if (ambient === undefined) {
|
|
127
|
+
throw new Error('Workflow ambient API called outside a TestWorkflowWorld run context')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return ambient
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type TestWorkflowRunInspection = {
|
|
134
|
+
readonly runId: string
|
|
135
|
+
readonly status: TestWorkflowRunStatus
|
|
136
|
+
readonly chunks: ReadonlyArray<unknown>
|
|
137
|
+
readonly streamClosed: boolean
|
|
138
|
+
readonly streamCloseCount: number
|
|
139
|
+
readonly writeAfterCloseAttempts: number
|
|
140
|
+
readonly stepAttempts: ReadonlyMap<string, number>
|
|
141
|
+
readonly runError: unknown
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type TestWorkflowHook<T> = Promise<T> & { readonly [Symbol.dispose]: () => void }
|
|
145
|
+
|
|
146
|
+
export class TestWorkflowWorld {
|
|
147
|
+
private readonly runs = new Map<string, TestWorkflowRunRecord>()
|
|
148
|
+
private runSequence = 0
|
|
149
|
+
|
|
150
|
+
start<TArgs extends ReadonlyArray<unknown>, TResult>(
|
|
151
|
+
workflowFn: (...args: TArgs) => Promise<TResult>,
|
|
152
|
+
args: TArgs
|
|
153
|
+
): { readonly runId: string } {
|
|
154
|
+
this.runSequence += 1
|
|
155
|
+
const runId = `twr_${this.runSequence}`
|
|
156
|
+
const run: TestWorkflowRunRecord = {
|
|
157
|
+
runId,
|
|
158
|
+
status: 'pending',
|
|
159
|
+
returnValue: undefined,
|
|
160
|
+
runError: undefined,
|
|
161
|
+
settled: Promise.resolve(),
|
|
162
|
+
chunks: [],
|
|
163
|
+
streamClosed: false,
|
|
164
|
+
streamCloseCount: 0,
|
|
165
|
+
writeAfterCloseAttempts: 0,
|
|
166
|
+
changeListeners: new Set(),
|
|
167
|
+
hooks: new Map(),
|
|
168
|
+
resumedHookTokens: new Set(),
|
|
169
|
+
stepSequence: 0,
|
|
170
|
+
stepAttempts: new Map()
|
|
171
|
+
}
|
|
172
|
+
this.runs.set(runId, run)
|
|
173
|
+
|
|
174
|
+
run.settled = ambientStorage.run(
|
|
175
|
+
{ world: this, run, stepMetadata: undefined },
|
|
176
|
+
async (): Promise<void> => {
|
|
177
|
+
// Yield once so callers observe `pending` before execution begins,
|
|
178
|
+
// mirroring the platform's enqueue-then-run lifecycle.
|
|
179
|
+
await Promise.resolve()
|
|
180
|
+
if (run.status === 'cancelled') return
|
|
181
|
+
|
|
182
|
+
run.status = 'running'
|
|
183
|
+
try {
|
|
184
|
+
run.returnValue = await workflowFn(...args)
|
|
185
|
+
if (run.status === 'running') run.status = 'completed'
|
|
186
|
+
} catch (error) {
|
|
187
|
+
run.runError = error
|
|
188
|
+
if (run.status === 'running') run.status = 'failed'
|
|
189
|
+
} finally {
|
|
190
|
+
this.notifyChange(run)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return { runId }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async settled(runId: string): Promise<void> {
|
|
199
|
+
await this.requireRun(runId).settled
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
inspect(runId: string): TestWorkflowRunInspection {
|
|
203
|
+
const run = this.requireRun(runId)
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
runId: run.runId,
|
|
207
|
+
status: run.status,
|
|
208
|
+
chunks: [...run.chunks],
|
|
209
|
+
streamClosed: run.streamClosed,
|
|
210
|
+
streamCloseCount: run.streamCloseCount,
|
|
211
|
+
writeAfterCloseAttempts: run.writeAfterCloseAttempts,
|
|
212
|
+
stepAttempts: new Map(run.stepAttempts),
|
|
213
|
+
runError: run.runError
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
status(runId: string): TestWorkflowRunStatus {
|
|
218
|
+
return this.requireRun(runId).status
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async cancel(runId: string): Promise<void> {
|
|
222
|
+
const run = this.requireRun(runId)
|
|
223
|
+
|
|
224
|
+
if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {
|
|
225
|
+
throw new TestWorkflowRunConflictError(
|
|
226
|
+
`Workflow run "${runId}" is already ${run.status} and cannot be cancelled`
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
run.status = 'cancelled'
|
|
231
|
+
for (const [token, hook] of run.hooks) {
|
|
232
|
+
run.hooks.delete(token)
|
|
233
|
+
hook.reject(new TestWorkflowRunCancelledError(runId))
|
|
234
|
+
}
|
|
235
|
+
this.notifyChange(run)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async resumeHook(token: string, payload: unknown): Promise<void> {
|
|
239
|
+
for (const run of this.runs.values()) {
|
|
240
|
+
const hook = run.hooks.get(token)
|
|
241
|
+
if (hook !== undefined) {
|
|
242
|
+
run.hooks.delete(token)
|
|
243
|
+
run.resumedHookTokens.add(token)
|
|
244
|
+
hook.resolve(payload)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (run.resumedHookTokens.has(token)) {
|
|
249
|
+
throw new TestWorkflowHookConflictError(token)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
throw new TestWorkflowHookNotFoundError(token)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
hasResumedHook(token: string): boolean {
|
|
257
|
+
for (const run of this.runs.values()) {
|
|
258
|
+
if (run.resumedHookTokens.has(token)) return true
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return false
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- Step executor (platform retry semantics) ---
|
|
265
|
+
|
|
266
|
+
// Runs a step body with the platform's retry contract: `fn.maxRetries`
|
|
267
|
+
// retries after the first attempt (total attempts = maxRetries + 1) and
|
|
268
|
+
// 1-based `getStepMetadata().attempt` visible to the body.
|
|
269
|
+
async runStep<TArgs extends ReadonlyArray<unknown>, TResult>(
|
|
270
|
+
stepFn: TestWorkflowStepFunction<TArgs, TResult>,
|
|
271
|
+
args: TArgs,
|
|
272
|
+
stepName = stepFn.name === '' ? 'step' : stepFn.name
|
|
273
|
+
): Promise<TResult> {
|
|
274
|
+
const ambient = requireAmbient()
|
|
275
|
+
const run = ambient.run
|
|
276
|
+
run.stepSequence += 1
|
|
277
|
+
const stepId = `${run.runId}:step:${run.stepSequence}:${stepName}`
|
|
278
|
+
const maxRetries = stepFn.maxRetries ?? defaultTestWorkflowStepMaxRetries
|
|
279
|
+
const maxAttempts = maxRetries + 1
|
|
280
|
+
|
|
281
|
+
let attempt = 1
|
|
282
|
+
|
|
283
|
+
for (;;) {
|
|
284
|
+
run.stepAttempts.set(stepName, (run.stepAttempts.get(stepName) ?? 0) + 1)
|
|
285
|
+
try {
|
|
286
|
+
return await ambientStorage.run(
|
|
287
|
+
{ world: this, run, stepMetadata: { attempt, stepId, stepName } },
|
|
288
|
+
() => stepFn(...args)
|
|
289
|
+
)
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (attempt >= maxAttempts) throw error
|
|
292
|
+
attempt += 1
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Wraps a step function so orchestration code can invoke it exactly the way
|
|
298
|
+
// the platform invokes `use step` functions: through the retry executor.
|
|
299
|
+
step<TArgs extends ReadonlyArray<unknown>, TResult>(
|
|
300
|
+
stepFn: TestWorkflowStepFunction<TArgs, TResult>,
|
|
301
|
+
stepName?: string
|
|
302
|
+
): (...args: TArgs) => Promise<TResult> {
|
|
303
|
+
return (...args: TArgs) => this.runStep(stepFn, args, stepName)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// --- Ambient platform APIs (exposed through `testWorkflowModule`) ---
|
|
307
|
+
|
|
308
|
+
static getWorkflowMetadata(): TestWorkflowMetadata {
|
|
309
|
+
return { workflowRunId: requireAmbient().run.runId }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
static getStepMetadata(): TestWorkflowStepMetadata {
|
|
313
|
+
const ambient = requireAmbient()
|
|
314
|
+
|
|
315
|
+
if (ambient.stepMetadata === undefined) {
|
|
316
|
+
throw new Error('getStepMetadata called outside a step context')
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return ambient.stepMetadata
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
static getWritable<T>(): WritableStream<T> {
|
|
323
|
+
const ambient = requireAmbient()
|
|
324
|
+
|
|
325
|
+
return ambient.world.makeWritable(ambient.run)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
static createHook<T>(input: { readonly token: string }): TestWorkflowHook<T> {
|
|
329
|
+
const ambient = requireAmbient()
|
|
330
|
+
|
|
331
|
+
return ambient.world.makeHook<T>(ambient.run, input.token)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- Durable streams ---
|
|
335
|
+
|
|
336
|
+
private makeWritable<T>(run: TestWorkflowRunRecord): WritableStream<T> {
|
|
337
|
+
return new WritableStream<T>({
|
|
338
|
+
write: chunk => {
|
|
339
|
+
if (run.streamClosed || run.status === 'cancelled') {
|
|
340
|
+
run.writeAfterCloseAttempts += 1
|
|
341
|
+
throw new TestWorkflowStreamConflictError(run.runId)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
run.chunks.push(chunk)
|
|
345
|
+
this.notifyChange(run)
|
|
346
|
+
},
|
|
347
|
+
close: () => {
|
|
348
|
+
if (run.streamClosed) {
|
|
349
|
+
run.streamCloseCount += 1
|
|
350
|
+
throw new TestWorkflowStreamConflictError(run.runId)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
run.streamClosed = true
|
|
354
|
+
run.streamCloseCount += 1
|
|
355
|
+
this.notifyChange(run)
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private makeHook<T>(run: TestWorkflowRunRecord, token: string): TestWorkflowHook<T> {
|
|
361
|
+
let resolveHook: (payload: unknown) => void = () => {}
|
|
362
|
+
let rejectHook: (error: unknown) => void = () => {}
|
|
363
|
+
const promise = new Promise<T>((resolve, reject) => {
|
|
364
|
+
// Hook payloads are caller-typed on the platform (`createHook<T>`).
|
|
365
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
366
|
+
resolveHook = payload => resolve(payload as T)
|
|
367
|
+
rejectHook = reject
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
run.hooks.set(token, { resolve: resolveHook, reject: rejectHook })
|
|
371
|
+
|
|
372
|
+
return Object.assign(promise, {
|
|
373
|
+
[Symbol.dispose]: () => {
|
|
374
|
+
run.hooks.delete(token)
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
getReadable<T>(
|
|
380
|
+
runId: string,
|
|
381
|
+
options?: VercelWorkflowReadableOptions
|
|
382
|
+
): VercelWorkflowReadableStream<T> {
|
|
383
|
+
const run = this.requireRun(runId)
|
|
384
|
+
const requestedStart = options?.startIndex ?? 0
|
|
385
|
+
let index =
|
|
386
|
+
requestedStart < 0 ? Math.max(0, run.chunks.length + requestedStart) : requestedStart
|
|
387
|
+
|
|
388
|
+
const stream = new ReadableStream<T>({
|
|
389
|
+
pull: async controller => {
|
|
390
|
+
while (index >= run.chunks.length && !this.streamEnded(run)) {
|
|
391
|
+
await this.nextChange(run)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (index < run.chunks.length) {
|
|
395
|
+
// The platform readable is caller-typed (`WorkflowReadableStream<R = any>`);
|
|
396
|
+
// the durable log stores opaque chunks, so this coercion mirrors the
|
|
397
|
+
// platform contract exactly.
|
|
398
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
399
|
+
controller.enqueue(run.chunks[index] as T)
|
|
400
|
+
index += 1
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
controller.close()
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
return Object.assign(stream, {
|
|
409
|
+
getTailIndex: () => Promise.resolve(run.chunks.length - 1)
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private streamEnded(run: TestWorkflowRunRecord): boolean {
|
|
414
|
+
return run.streamClosed || run.status === 'cancelled'
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private notifyChange(run: TestWorkflowRunRecord): void {
|
|
418
|
+
const listeners = [...run.changeListeners]
|
|
419
|
+
run.changeListeners.clear()
|
|
420
|
+
for (const listener of listeners) listener()
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private nextChange(run: TestWorkflowRunRecord): Promise<void> {
|
|
424
|
+
return new Promise(resolve => {
|
|
425
|
+
run.changeListeners.add(resolve)
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private requireRun(runId: string): TestWorkflowRunRecord {
|
|
430
|
+
const run = this.runs.get(runId)
|
|
431
|
+
|
|
432
|
+
if (run === undefined) throw new TestWorkflowRunNotFoundError(runId)
|
|
433
|
+
|
|
434
|
+
return run
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// --- SDK adapter for `VercelWorkflows.layerFromSdk` ---
|
|
438
|
+
|
|
439
|
+
private sdkRun<TResult>(run: TestWorkflowRunRecord): VercelWorkflowsSdkRun<TResult> {
|
|
440
|
+
const getReadable = <Chunk>(options?: VercelWorkflowReadableOptions) =>
|
|
441
|
+
this.getReadable<Chunk>(run.runId, options)
|
|
442
|
+
const cancel = () => this.cancel(run.runId)
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
runId: run.runId,
|
|
446
|
+
getReadable,
|
|
447
|
+
cancel,
|
|
448
|
+
get status() {
|
|
449
|
+
return Promise.resolve(run.status)
|
|
450
|
+
},
|
|
451
|
+
get returnValue() {
|
|
452
|
+
return run.settled.then(() => {
|
|
453
|
+
// Return values are caller-typed on the platform SDK as well.
|
|
454
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
455
|
+
if (run.status === 'completed') return run.returnValue as TResult
|
|
456
|
+
|
|
457
|
+
throw run.runError ?? new TestWorkflowRunCancelledError(run.runId)
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
readonly sdk: VercelWorkflowsSdkClient = {
|
|
464
|
+
start: async (workflowFn, args) => {
|
|
465
|
+
const { runId } = this.start(workflowFn, args)
|
|
466
|
+
|
|
467
|
+
return this.sdkRun(this.requireRun(runId))
|
|
468
|
+
},
|
|
469
|
+
getRun: <TResult>(runId: string) => this.sdkRun<TResult>(this.requireRun(runId)),
|
|
470
|
+
resumeHook: (token, payload) => this.resumeHook(token, payload)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Factory for `vi.mock('workflow', () => testWorkflowModule)`: the mocked
|
|
475
|
+
// module surface delegates every ambient platform API to the active world's
|
|
476
|
+
// AsyncLocalStorage context, so production code under test runs unmodified.
|
|
477
|
+
export const testWorkflowModule = {
|
|
478
|
+
getWorkflowMetadata: () => TestWorkflowWorld.getWorkflowMetadata(),
|
|
479
|
+
getStepMetadata: () => TestWorkflowWorld.getStepMetadata(),
|
|
480
|
+
getWritable: <T>() => TestWorkflowWorld.getWritable<T>(),
|
|
481
|
+
createHook: <T>(input: { readonly token: string }) => TestWorkflowWorld.createHook<T>(input)
|
|
482
|
+
}
|