@inngest/test
Version:
Tooling for testing Inngest functions.
380 lines (379 loc) • 17.9 kB
JavaScript
;
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.InngestTestEngine = void 0;
const internals_1 = require("inngest/internals");
const types_1 = require("inngest/types");
const ulid_1 = require("ulid");
const InngestTestRun_js_1 = require("./InngestTestRun.js");
const util_js_1 = require("./util.js");
/**
* A test engine for running Inngest functions in a test environment, providing
* the ability to assert inputs, outputs, and step usage, as well as mocking
* with support for popular testing libraries.
*/
class InngestTestEngine {
constructor(options) {
this.mockHandlerCache = {};
this.options = options;
}
/**
* Create a new test engine with the given inline options merged with the
* existing options.
*/
clone(inlineOpts) {
const cloned = new InngestTestEngine(Object.assign(Object.assign({}, this.options), inlineOpts));
// Share the same mock handler cache to maintain memoization across clones
cloned.mockHandlerCache = this.mockHandlerCache;
return cloned;
}
/**
* Start a run from the given state and keep executing the function until a
* specific checkpoint is reached.
*
* Is a shortcut for and uses `run.waitFor()`.
*/
execute(
/**
* Options and state to start the run with.
*/
inlineOpts) {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.individualExecution(inlineOpts);
const resolutionHandler = (output) => {
return {
ctx: output.ctx,
state: output.state,
result: output.result.data,
};
};
const rejectionHandler = (output) => {
if (typeof output === "object" &&
output !== null &&
"ctx" in output &&
"state" in output) {
let error = output.result.error;
if (!error) {
if ("step" in output.result &&
typeof output.result.step === "object" &&
output.result.step !== null &&
"error" in output.result.step &&
output.result.step.error) {
error = output.result.step.error;
}
else {
error = new Error("Function rejected without a visible error; this is a bug");
}
}
return {
ctx: output.ctx,
state: output.state,
error,
};
}
throw output;
};
if (output.result.type === "function-resolved") {
return resolutionHandler(output);
}
else if (output.result.type === "function-rejected") {
return rejectionHandler(output);
}
else if (output.result.type === "step-ran") {
// Any error halts execution until retries are modelled
if (output.result.step
.error) {
return rejectionHandler(output);
}
}
return output.run
.waitFor("function-resolved")
.then(resolutionHandler)
.catch(rejectionHandler);
});
}
/**
* Start a run from the given state and keep executing the function until the
* given step has run.
*/
executeStep(
/**
* The ID of the step to execute.
*/
stepId,
/**
* Options and state to start the run with.
*/
inlineOpts) {
return __awaiter(this, void 0, void 0, function* () {
const { run } = yield this.individualExecution(Object.assign(Object.assign({}, inlineOpts), {
// always overwrite this so it's easier to capture non-runnable steps in
// the same flow.
disableImmediateExecution: true }));
const foundSteps = yield run.waitFor("steps-found", {
steps: [{ id: stepId }],
});
const hashedStepId = internals_1.InngestExecutionEngine._internals.hashId(stepId);
const step = foundSteps.result.steps.find((step) => step.id === hashedStepId);
// never found the step? Unexpected.
if (!step) {
throw new Error(`Step "${stepId}" not found, but execution was still paused. This is a bug.`);
}
// if this is not a runnable step, return it now
// runnable steps should return void
//
// some of these ops are nonsensical for the checkpoint we're waiting for,
// but we consider them anyway to ensure that this type requires attention
// if we add more opcodes
const baseRet = {
ctx: foundSteps.ctx,
state: foundSteps.state,
step,
};
const opHandlers = {
// runnable, so do nothing now
[types_1.StepOpCode.StepPlanned]: () => { },
[types_1.StepOpCode.InvokeFunction]: () => baseRet,
[types_1.StepOpCode.Sleep]: () => baseRet,
[types_1.StepOpCode.StepError]: () => (Object.assign(Object.assign({}, baseRet), { error: step.error })),
[types_1.StepOpCode.StepNotFound]: () => baseRet,
[types_1.StepOpCode.StepRun]: () => (Object.assign(Object.assign({}, baseRet), { result: step.data })),
[types_1.StepOpCode.WaitForEvent]: () => baseRet,
[types_1.StepOpCode.WaitForSignal]: () => baseRet,
[types_1.StepOpCode.Step]: () => (Object.assign(Object.assign({}, baseRet), { result: step.data })),
[types_1.StepOpCode.StepFailed]: () => (Object.assign(Object.assign({}, baseRet), { error: step.error })),
[types_1.StepOpCode.AiGateway]: () => baseRet,
[types_1.StepOpCode.Gateway]: () => baseRet,
[types_1.StepOpCode.RunComplete]: () => baseRet,
[types_1.StepOpCode.DiscoveryRequest]: () => baseRet,
};
const result = opHandlers[step.op]();
if (result) {
return result;
}
// otherwise, run the step and return the output
const runOutput = yield run.waitFor("step-ran", {
step: { id: stepId },
});
return {
ctx: runOutput.ctx,
state: runOutput.state,
result: runOutput.result.step.data,
error: runOutput.result.step.error,
step: runOutput.result.step,
};
});
}
/**
* Start a run from the given state and keep executing the function until a
* specific checkpoint is reached.
*
* Is a shortcut for and uses `run.waitFor()`.
*
* @TODO This is a duplicate of `execute` and will probably be removed; it's a
* very minor convenience method that deals too much with the internals.
*/
executeAndWaitFor(
/**
* The checkpoint to wait for.
*/
checkpoint,
/**
* Options and state to start the run with.
*/
inlineOpts) {
return __awaiter(this, void 0, void 0, function* () {
const { run } = yield this.individualExecution(inlineOpts);
return run.waitFor(checkpoint, inlineOpts === null || inlineOpts === void 0 ? void 0 : inlineOpts.subset);
});
}
/**
* Execute the function with the given inline options.
*/
individualExecution(inlineOpts) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
const options = Object.assign(Object.assign({}, this.options), inlineOpts);
const events = (options.events || [(0, util_js_1.createMockEvent)()]).map((event) => {
// Make sure every event has some basic mocked data
return Object.assign(Object.assign({}, (0, util_js_1.createMockEvent)()), event);
});
const steps = (options.steps || []).map((step) => {
return Object.assign(Object.assign({}, step), { id: step.idIsHashed
? step.id
: internals_1.InngestExecutionEngine._internals.hashId(step.id) });
});
const stepState = {};
steps.forEach((step) => {
const { promise: data, resolve: resolveData } = (0, util_js_1.createDeferredPromise)();
const { promise: error, resolve: resolveError } = (0, util_js_1.createDeferredPromise)();
const mockHandler = Object.assign(Object.assign({}, step), { data,
error, __lazyMockHandler: (state) => __awaiter(this, void 0, void 0, function* () {
resolveError(state.error);
resolveData(state.data);
}) });
stepState[step.id] = mockHandler;
// Register this lazy handler so it gets called when the mock executes
let cacheEntry = this.mockHandlerCache[step.id];
if (!cacheEntry) {
cacheEntry = {
executed: false,
lazyHandlers: [],
};
this.mockHandlerCache[step.id] = cacheEntry;
}
cacheEntry.lazyHandlers.push(mockHandler.__lazyMockHandler);
});
// Helper to execute the mock handler, with memoization across executions
const executeMockHandler = (mockStep, stepId) => __awaiter(this, void 0, void 0, function* () {
var _a;
// Ensure cache entry exists (should always exist from the forEach above)
if (!this.mockHandlerCache[stepId]) {
this.mockHandlerCache[stepId] = {
executed: false,
lazyHandlers: [],
};
}
const cacheEntry = this.mockHandlerCache[stepId];
// If already executed or executing, wait for it and notify this execution's handler
if (cacheEntry.executed || cacheEntry.promise) {
yield cacheEntry.promise;
yield ((_a = mockStep.__lazyMockHandler) === null || _a === void 0 ? void 0 : _a.call(mockStep, {
data: cacheEntry.data,
error: cacheEntry.error,
}));
return;
}
// Mark as executing immediately to prevent race conditions
cacheEntry.executed = true;
// Execute the handler only once
cacheEntry.promise = new Promise((resolve) => __awaiter(this, void 0, void 0, function* () {
try {
const data = yield mockStep.handler();
cacheEntry.data = data;
// Notify all registered lazy handlers (from all executions)
yield Promise.all(cacheEntry.lazyHandlers.map((handler) => handler({ data })));
}
catch (err) {
const error = internals_1.errors.serializeError(err);
cacheEntry.error = error;
// Notify all registered lazy handlers (from all executions)
yield Promise.all(cacheEntry.lazyHandlers.map((handler) => handler({ error })));
}
finally {
resolve();
}
}));
return cacheEntry.promise;
});
// Helper to wrap a promise so it executes the handler when awaited (via .then)
const wrapLazyPromise = (promise, mockStep, stepId) => {
return new Proxy(promise, {
get(target, prop) {
if (prop === "then") {
return function (...args) {
return executeMockHandler(mockStep, stepId).then(() => target.then(...args));
};
}
const value = target[prop];
return typeof value === "function" ? value.bind(target) : value;
},
});
};
// Track mock step accesses; execute handler only when the step actually needs to run
// (when its promise is awaited), not when the property is merely accessed.
//
// This gives us the ability for mocks to be async and return dynamic data.
const mockStepState = new Proxy(stepState, {
get(target, prop) {
if (!(prop in target)) {
return undefined;
}
const stepId = prop;
const mockStep = target[prop];
// Wrap the mockStep in a proxy that intercepts promise .then() calls
return new Proxy(mockStep, {
get(stepTarget, stepProp) {
const value = stepTarget[stepProp];
// If accessing data or error promises, wrap them for execution on .then()
if ((stepProp === "data" || stepProp === "error") &&
value instanceof Promise) {
return wrapLazyPromise(value, stepTarget, stepId);
}
return value;
},
});
},
});
const runId = (0, ulid_1.ulid)();
const execution = options.function["createExecution"]({
partialOptions: {
runId,
client: options.function["client"],
data: {
runId,
attempt: 0, // TODO retries?
event: events[0],
events,
},
reqArgs: options.reqArgs || [],
headers: {},
stepCompletionOrder: steps.map((step) => step.id),
stepState: mockStepState,
stepMode: types_1.StepMode.Async,
disableImmediateExecution: Boolean(options.disableImmediateExecution),
isFailureHandler: false, // TODO need to allow hitting an `onFailure` handler - not dynamically, but choosing it
timer: new internals_1.ServerTiming.ServerTiming({
info: () => { },
warn: () => { },
error: () => { },
debug: () => { },
}),
requestedRunStep: options.targetStepId,
transformCtx: (_a = this.options.transformCtx) !== null && _a !== void 0 ? _a : util_js_1.mockCtx,
},
});
const _b = yield execution.start(), { ctx, ops } = _b, result = __rest(_b, ["ctx", "ops"]);
const mockState = yield Object.keys(ops).reduce((acc, stepId) => __awaiter(this, void 0, void 0, function* () {
const op = ops[stepId];
if ((op === null || op === void 0 ? void 0 : op.seen) === false ||
!(op === null || op === void 0 ? void 0 : op.rawArgs) ||
!(op === null || op === void 0 ? void 0 : op.fulfilled) ||
!(op === null || op === void 0 ? void 0 : op.promise)) {
return acc;
}
return Object.assign(Object.assign({}, (yield acc)), { [stepId]: op.promise });
}), Promise.resolve({}));
InngestTestRun_js_1.InngestTestRun["updateState"](options, result);
const run = new InngestTestRun_js_1.InngestTestRun({
testEngine: this.clone(options),
});
return {
result,
ctx: ctx,
state: mockState,
run,
};
});
}
}
exports.InngestTestEngine = InngestTestEngine;