UNPKG

@inngest/test

Version:
380 lines (379 loc) 17.9 kB
"use strict"; 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;