diff --git a/.changeset/few-brooms-remember.md b/.changeset/few-brooms-remember.md new file mode 100644 index 000000000..18f6f88bf --- /dev/null +++ b/.changeset/few-brooms-remember.md @@ -0,0 +1,5 @@ +--- +"inngest": patch +--- + +Expose some internal execution logic to make way for a new `@inngest/test` package diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 4ee97b100..1be74b975 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -206,3 +206,55 @@ jobs: The last release was built and published from ${{ github.event.pull_request.head.sha }}. edit-mode: replace + + prerelease_test: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + defaults: + run: + working-directory: packages/test + if: contains(github.event.pull_request.labels.*.name, 'prerelease/test') + steps: + - uses: actions/checkout@v3 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-and-build + with: + install-dependencies: false + build: false + + - run: pnpm install + + - run: pnpm build + + - name: Prerelease PR + run: node ../../scripts/release/prerelease.js + env: + TAG: pr-${{ github.event.pull_request.number }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_ENV: test # disable npm access checks; they don't work in CI + DIST_DIR: dist + + - name: Update PR with latest prerelease + uses: edumserrano/find-create-or-update-comment@v1 + with: + token: ${{ secrets.CHANGESET_GITHUB_TOKEN }} + issue-number: ${{ github.event.pull_request.number }} + body-includes: "" + comment-author: "inngest-release-bot" + body: + | # can be a single value or you can compose text with multi-line values + + A user has added the [prerelease/test](https://github.com/inngest/inngest-js/labels/prerelease%2Ftest) label, so this PR will be published to npm with the tag `pr-${{ github.event.pull_request.number }}`. It will be updated with the latest changes as you push commits to this PR. + + You can install this prerelease version with: + + ```sh + npm install @inngest/test@pr-${{ github.event.pull_request.number }} + ``` + + The last release was built and published from ${{ github.event.pull_request.head.sha }}. + edit-mode: replace diff --git a/packages/inngest/src/components/InngestMiddleware.test.ts b/packages/inngest/src/components/InngestMiddleware.test.ts index 6f0d0017e..6aebfba5a 100644 --- a/packages/inngest/src/components/InngestMiddleware.test.ts +++ b/packages/inngest/src/components/InngestMiddleware.test.ts @@ -464,7 +464,7 @@ describe("stacking and inference", () => { }, }), ], - }).createFunction({ id: "" }, { event: "" }, ({ step }) => { + }).createFunction({ id: "" }, { event: "" }, () => { throw new Error("test error"); }); diff --git a/packages/inngest/src/components/InngestStepTools.ts b/packages/inngest/src/components/InngestStepTools.ts index 29044e6dc..d74bd765c 100644 --- a/packages/inngest/src/components/InngestStepTools.ts +++ b/packages/inngest/src/components/InngestStepTools.ts @@ -35,6 +35,7 @@ import { type InngestExecution } from "./execution/InngestExecution"; export interface FoundStep extends HashedOp { hashedId: string; fn?: (...args: unknown[]) => unknown; + rawArgs: unknown[]; fulfilled: boolean; handled: boolean; @@ -42,7 +43,7 @@ export interface FoundStep extends HashedOp { * Returns a boolean representing whether or not the step was handled on this * invocation. */ - handle: () => boolean; + handle: () => Promise; } export type MatchOpFn< diff --git a/packages/inngest/src/components/execution/InngestExecution.ts b/packages/inngest/src/components/execution/InngestExecution.ts index f77b02ee9..0a45c542f 100644 --- a/packages/inngest/src/components/execution/InngestExecution.ts +++ b/packages/inngest/src/components/execution/InngestExecution.ts @@ -19,7 +19,13 @@ export interface ExecutionResults { } export type ExecutionResult = { - [K in keyof ExecutionResults]: Simplify<{ type: K } & ExecutionResults[K]>; + [K in keyof ExecutionResults]: Simplify< + { + type: K; + ctx: Context.Any; + ops: Record; + } & ExecutionResults[K] + >; }[keyof ExecutionResults]; export type ExecutionResultHandler = ( @@ -31,6 +37,11 @@ export type ExecutionResultHandlers = { }; export interface MemoizedOp extends IncomingOp { + /** + * If the step has been hit during this run, these will be the arguments + * passed to it. + */ + rawArgs?: unknown[]; fulfilled?: boolean; seen?: boolean; } @@ -63,6 +74,12 @@ export interface InngestExecutionOptions { timer?: ServerTiming; isFailureHandler?: boolean; disableImmediateExecution?: boolean; + + /** + * Provide the ability to transform the context passed to the function before + * the execution starts. + */ + transformCtx?: (ctx: Readonly) => Context.Any; } export type InngestExecutionFactory = ( diff --git a/packages/inngest/src/components/execution/v0.ts b/packages/inngest/src/components/execution/v0.ts index e8b5cee2d..5f9476c51 100644 --- a/packages/inngest/src/components/execution/v0.ts +++ b/packages/inngest/src/components/execution/v0.ts @@ -43,6 +43,7 @@ import { type IInngestExecution, type InngestExecutionFactory, type InngestExecutionOptions, + type MemoizedOp, } from "./InngestExecution"; export const createV0InngestExecution: InngestExecutionFactory = (options) => { @@ -188,7 +189,12 @@ export class V0InngestExecution const { type: _type, ...rest } = result; - return { type: "step-ran", step: { ...outgoingUserFnOp, ...rest } }; + return { + type: "step-ran", + ctx: this.fnArg, + ops: this.ops, + step: { ...outgoingUserFnOp, ...rest }, + }; } if (!discoveredOps.length) { @@ -235,6 +241,8 @@ export class V0InngestExecution return { type: "steps-found", + ctx: this.fnArg, + ops: this.ops, steps: discoveredOps as [OutgoingOp, ...OutgoingOp[]], }; } catch (error) { @@ -312,6 +320,24 @@ export class V0InngestExecution return state; } + get ops(): Record { + return Object.fromEntries( + Object.entries(this.state.allFoundOps).map<[string, MemoizedOp]>( + ([id, op]) => [ + id, + { + id: op.id, + rawArgs: op.rawArgs, + data: op.data, + error: op.error, + fulfilled: op.fulfilled, + seen: true, + }, + ] + ) + ); + } + private getUserFnToRun(): Handler.Any { if (!this.options.isFailureHandler) { return this.options.fn["fn"]; @@ -406,6 +432,7 @@ export class V0InngestExecution this.state.tickOps[opId.id] = { ...opId, ...(opts?.fn ? { fn: () => opts.fn?.(...args) } : {}), + rawArgs: args, resolve, reject, fulfilled: false, @@ -431,7 +458,7 @@ export class V0InngestExecution }; } - return fnArg; + return this.options.transformCtx?.(fnArg) ?? fnArg; } /** @@ -508,14 +535,26 @@ export class V0InngestExecution const serializedError = serializeError(error); - return { type: "function-rejected", error: serializedError, retriable }; + return { + type: "function-rejected", + ctx: this.fnArg, + ops: this.ops, + error: serializedError, + retriable, + }; } - return { type: "function-resolved", data: undefinedToNull(data) }; + return { + type: "function-resolved", + ctx: this.fnArg, + ops: this.ops, + data: undefinedToNull(data), + }; } } interface TickOp extends HashedOp { + rawArgs: unknown[]; fn?: (...args: unknown[]) => unknown; fulfilled: boolean; resolve: (value: MaybePromise) => void; diff --git a/packages/inngest/src/components/execution/v1.ts b/packages/inngest/src/components/execution/v1.ts index 29f8a18dd..639baced3 100644 --- a/packages/inngest/src/components/execution/v1.ts +++ b/packages/inngest/src/components/execution/v1.ts @@ -179,6 +179,8 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { if (transformResult.type === "function-resolved") { return { type: "step-ran", + ctx: transformResult.ctx, + ops: transformResult.ops, step: _internals.hashOp({ ...stepResult, data: transformResult.data, @@ -188,6 +190,8 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { } else if (transformResult.type === "function-rejected") { return { type: "step-ran", + ctx: transformResult.ctx, + ops: transformResult.ops, step: _internals.hashOp({ ...stepResult, error: transformResult.error, @@ -205,6 +209,8 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { if (newSteps) { return { type: "steps-found", + ctx: this.fnArg, + ops: this.ops, steps: newSteps, }; } @@ -215,7 +221,7 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { * timed out or have otherwise decided that it doesn't exist. */ "step-not-found": ({ step }) => { - return { type: "step-not-found", step }; + return { type: "step-not-found", ctx: this.fnArg, ops: this.ops, step }; }, }; } @@ -563,10 +569,21 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { const serializedError = minifyPrettyError(serializeError(error)); - return { type: "function-rejected", error: serializedError, retriable }; + return { + type: "function-rejected", + ctx: this.fnArg, + ops: this.ops, + error: serializedError, + retriable, + }; } - return { type: "function-resolved", data: undefinedToNull(data) }; + return { + type: "function-resolved", + ctx: this.fnArg, + ops: this.ops, + data: undefinedToNull(data), + }; } private createExecutionState(): V1ExecutionState { @@ -611,6 +628,10 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { return state; } + get ops(): Record { + return this.state.steps; + } + private createFnArg(): Context.Any { const step = this.createStepTools(); @@ -633,7 +654,7 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { }; } - return fnArg; + return this.options.transformCtx?.(fnArg) ?? fnArg; } private createStepTools(): ReturnType { @@ -839,13 +860,14 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { const step: FoundStep = { ...opId, + rawArgs: args, hashedId, // eslint-disable-next-line @typescript-eslint/no-unsafe-argument fn: opts?.fn ? () => opts.fn?.(...args) : undefined, fulfilled: Boolean(stepState), displayName: opId.displayName ?? opId.id, handled: false, - handle: () => { + handle: async () => { if (step.handled) { return false; } @@ -855,6 +877,13 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { if (stepState) { stepState.fulfilled = true; + // For some execution scenarios such as testing, `data` and/or + // `error` may be `Promises`. This could also be the case for future + // middleware applications. For this reason, we'll make sure the + // values are fully resolved before continuing. + await stepState.data; + await stepState.error; + if (typeof stepState.data !== "undefined") { resolve(stepState.data); } else { diff --git a/packages/inngest/src/test/helpers.ts b/packages/inngest/src/test/helpers.ts index b3274b0d4..a25c1fbde 100644 --- a/packages/inngest/src/test/helpers.ts +++ b/packages/inngest/src/test/helpers.ts @@ -11,9 +11,9 @@ import { } from "@local/components/InngestStepTools"; import { ExecutionVersion, - IInngestExecution, - InngestExecution, - InngestExecutionOptions, + type IInngestExecution, + type InngestExecution, + type InngestExecutionOptions, PREFERRED_EXECUTION_VERSION, } from "@local/components/execution/InngestExecution"; import { ServerTiming } from "@local/helpers/ServerTiming"; @@ -25,7 +25,7 @@ import { } from "@local/helpers/consts"; import { type Env } from "@local/helpers/env"; import { slugify } from "@local/helpers/strings"; -import { EventPayload, type FunctionConfig } from "@local/types"; +import { type EventPayload, type FunctionConfig } from "@local/types"; import { fromPartial } from "@total-typescript/shoehorn"; import fetch from "cross-fetch"; import { type Request, type Response } from "express"; @@ -120,7 +120,7 @@ export type StepTools = ReturnType; * Given an Inngest function and the appropriate execution state, return the * resulting data from this execution. */ -export const runFnWithStack = ( +export const runFnWithStack = async ( fn: InngestFunction.Any, stepState: InngestExecutionOptions["stepState"], opts?: { @@ -150,7 +150,9 @@ export const runFnWithStack = ( }, }); - return execution.start(); + const { ctx: _ctx, ops: _ops, ...rest } = await execution.start(); + + return rest; }; const inngest = createClient({ id: "test", eventKey: "event-key-123" }); diff --git a/packages/test/.gitignore b/packages/test/.gitignore new file mode 100644 index 000000000..de4d1f007 --- /dev/null +++ b/packages/test/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/packages/test/LICENSE.md b/packages/test/LICENSE.md new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/packages/test/LICENSE.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/test/README.md b/packages/test/README.md new file mode 100644 index 000000000..df3ae75cd --- /dev/null +++ b/packages/test/README.md @@ -0,0 +1,5 @@ +# @inngest/test + +- [ ] TODO Make Inngest a peer dep once package changes are shipped +- [ ] TODO Re-add release scripts +- [ ] TODO Copy `CHANGELOG.md` diff --git a/packages/test/eslint.config.js b/packages/test/eslint.config.js new file mode 100644 index 000000000..749472ea0 --- /dev/null +++ b/packages/test/eslint.config.js @@ -0,0 +1,9 @@ +// @ts-check + +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended +); diff --git a/packages/test/jest.config.js b/packages/test/jest.config.js new file mode 100644 index 000000000..8e30a2417 --- /dev/null +++ b/packages/test/jest.config.js @@ -0,0 +1,8 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} **/ +module.exports = { + testEnvironment: "node", + transform: { + "^.+.tsx?$": ["ts-jest", {}], + }, + roots: ["/src"], +}; diff --git a/packages/test/jsr.json b/packages/test/jsr.json new file mode 100644 index 000000000..c76621c29 --- /dev/null +++ b/packages/test/jsr.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://jsr.io/schema/config-file.v1.json", + "name": "@inngest/test", + "description": "Tooling for testing Inngest functions.", + "version": "0.0.0", + "include": ["./src/**/*.ts"], + "exclude": [], + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/test/package.json b/packages/test/package.json new file mode 100644 index 000000000..9d2ddaaa7 --- /dev/null +++ b/packages/test/package.json @@ -0,0 +1,44 @@ +{ + "name": "@inngest/test", + "version": "0.0.0", + "description": "Tooling for testing Inngest functions.", + "main": "./index.js", + "types": "./index.d.ts", + "publishConfig": { + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "test": "jest", + "build": "pnpm run build:clean && pnpm run build:tsc && pnpm run build:copy", + "build:clean": "rm -rf ./dist", + "build:tsc": "tsc --project tsconfig.build.json", + "build:copy": "cp package.json LICENSE.md README.md dist", + "pack": "pnpm run build && yarn pack --verbose --frozen-lockfile --filename inngest-test.tgz --cwd dist" + }, + "exports": { + ".": { + "require": "./index.js", + "import": "./index.js", + "types": "./index.d.ts" + } + }, + "keywords": [ + "inngest", + "test", + "testing" + ], + "homepage": "https://github.com/inngest/inngest-js/tree/main/packages/test#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/inngest/inngest-js.git", + "directory": "packages/test" + }, + "author": "Jack Williams ", + "license": "Apache-2.0", + "devDependencies": { + "@jest/globals": "^29.5.0" + }, + "dependencies": { + "ulid": "^2.3.0" + } +} diff --git a/packages/test/src/InngestTestEngine.ts b/packages/test/src/InngestTestEngine.ts new file mode 100644 index 000000000..251356e67 --- /dev/null +++ b/packages/test/src/InngestTestEngine.ts @@ -0,0 +1,367 @@ +import { jest } from "@jest/globals"; +import { + ExecutionVersion, + type MemoizedOp, +} from "inngest/components/execution/InngestExecution"; +import { _internals } from "inngest/components/execution/v1"; +import type { InngestFunction } from "inngest/components/InngestFunction"; +import { serializeError } from "inngest/helpers/errors"; +import { createDeferredPromise } from "inngest/helpers/promises"; +import { ServerTiming } from "inngest/helpers/ServerTiming"; +import { Context, EventPayload } from "inngest/types"; +import { ulid } from "ulid"; +import { InngestTestRun } from "./InngestTestRun.js"; +import { createMockEvent, mockCtx } from "./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. + */ +export namespace InngestTestEngine { + /** + * Options for creating a new {@link InngestTestEngine} instance. + */ + export interface Options { + /** + * The function to test. + * + * TODO Potentially later allow many functions such that we can invoke and + * send events. + */ + function: InngestFunction.Any; + + /** + * The event payloads to send to the function. If none is given, an + * "inngest/function.invoked" event will be mocked. + */ + events?: [EventPayload, ...EventPayload[]]; + + /** + * Previous step state to use for this execution. If not provided, none will + * be used. It's recommended to use `run.waitFor()`, where this will be + * filled automatically as the run progresses. + */ + steps?: MockedStep[]; + + /** + * The human-readable ID of the step that this execution is attempting to + * run. This is mostly an internal detail; it's recommended to use + * `run.waitFor()`, where this will be filled automatically as the run + * progresses. + */ + targetStepId?: string; + + /** + * An internal option to disable immediate execution of steps during + * parallelism. It's recommended to use `run.waitFor()`, where this will be + * filled automatically as the run progresses. + */ + disableImmediateExecution?: boolean; + + /** + * A function that can transform the context sent to the function upon + * execution, useful for mocking steps, events, or tracking property + * accesses with proxies. + * + * By default, this will change all `step.*` tools to be mocked functions so + * that you can assert their usage, input, and output. If you specify this + * option yourself, you'll overwrite this behavior. + * + * If you wish to keep this behaviour and make additional changes, you can + * use the `mockContext` function exported from this module. + * + * @example Transforming in addition to the defaults + * ```ts + * import { mockCtx } from "@inngest/test"; + * + * { + * transformCtx: (rawCtx) => { + * const ctx = mockCtx(rawCtx); + * + * // your other changes + * + * return ctx; + * }, + * } + * ``` + */ + transformCtx?: (ctx: Context.Any) => Context.Any; + } + + export interface MockedStep { + id: string; + handler: () => any; + } + + /** + * A mocked context object that allows you to assert step usage, input, and + * output. + */ + export interface MockContext extends Omit { + step: { + [K in keyof Context.Any["step"]]: jest.Mock; + }; + } + + /** + * Options that can be passed to an existing execution or run to continue + * execution. + */ + export type InlineOptions = Omit; + + /** + * A mocked state object that allows you to assert step usage, input, and + * output. + */ + export type MockState = Record< + string, + jest.Mock<(...args: unknown[]) => Promise> + >; + + /** + * The output of an individual function execution. + */ + export interface ExecutionOutput< + T extends InngestTestRun.CheckpointKey = InngestTestRun.CheckpointKey, + > { + /** + * The result of the execution. + */ + result: InngestTestRun.Checkpoint; + + /** + * The mocked context object that allows you to assert step usage, input, + * and output. + * + * @TODO This type may vary is `transformCtx` is given. + */ + ctx: InngestTestEngine.MockContext; + + /** + * The mocked state object that allows you to assert step usage, input, and + * output. + */ + state: InngestTestEngine.MockState; + + /** + * An {@link InngestTestRun} instance that allows you to wait for specific + * checkpoints in the execution. + */ + run: InngestTestRun; + } +} + +interface InternalMemoizedOp extends MemoizedOp { + __lazyMockHandler?: (state: { data?: any; error?: any }) => Promise; + __mockResult?: Promise; +} + +/** + * 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. + */ +export class InngestTestEngine { + protected options: InngestTestEngine.Options; + + constructor(options: InngestTestEngine.Options) { + this.options = options; + } + + /** + * Create a new test engine with the given inline options merged with the + * existing options. + */ + public clone( + inlineOpts?: InngestTestEngine.InlineOptions + ): InngestTestEngine { + return new InngestTestEngine({ ...this.options, ...inlineOpts }); + } + + /** + * 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()`. + */ + public async executeAndWaitFor( + /** + * Options and state to start the run with. + */ + inlineOpts: InngestTestEngine.InlineOptions, + + /** + * The checkpoint to wait for. + */ + checkpoint: T, + + /** + * An optional subset of the checkpoint to match against. Any checkpoint of + * this type will be matched. + * + * When providing a `subset`, use `expect` tooling such as + * `expect.stringContaining` to match partial values. + */ + subset?: Partial> + ): Promise> { + const { run } = await this.execute(inlineOpts); + + return run.waitFor(checkpoint, subset); + } + + /** + * Execute the function with the given inline options. + */ + public async execute( + inlineOpts?: InngestTestEngine.InlineOptions + ): Promise { + const options = { + ...this.options, + ...inlineOpts, + }; + + const events = (options.events || [createMockEvent()]).map((event) => { + // Make sure every event has some basic mocked data + return { + ...createMockEvent(), + ...event, + }; + }) as [EventPayload, ...EventPayload[]]; + + const steps = (options.steps || []).map((step) => { + return { + ...step, + id: _internals.hashId(step.id), + }; + }); + + const stepState: Record = {}; + + steps.forEach((step) => { + const { promise: data, resolve: resolveData } = createDeferredPromise(); + const { promise: error, resolve: resolveError } = createDeferredPromise(); + + const mockHandler = { + ...(step as MemoizedOp), + data, + error, + __lazyMockHandler: async (state) => { + resolveError(state.error); + resolveData(state.data); + }, + } satisfies InternalMemoizedOp; + + stepState[step.id] = mockHandler; + }); + + // Track mock step accesses; if we attempt to get a particular step then + // assume we've found it and attempt to lazily run the handler to give us + // time to return smarter mocked data based on input and other outputs. + // + // This gives us the ability for mocks be be async and return dynamic data. + const mockStepState = new Proxy(stepState, { + get(target, prop) { + if (!(prop in target)) { + return undefined; + } + + const mockStep = target[ + prop as keyof typeof target + ] as InternalMemoizedOp; + + // kick off the handler if we haven't already + mockStep.__mockResult ??= new Promise(async (resolve) => { + try { + mockStep.__lazyMockHandler?.({ + // TODO pass it a context then mate + data: await (mockStep as InngestTestEngine.MockedStep).handler(), + }); + } catch (err) { + mockStep.__lazyMockHandler?.({ error: serializeError(err) }); + } finally { + resolve(); + } + }); + + return mockStep; + }, + }); + + const runId = ulid(); + + const execution = options.function["createExecution"]({ + version: ExecutionVersion.V1, + partialOptions: { + runId, + data: { + runId, + attempt: 0, // TODO retries? + event: events[0], + events, + }, + reqArgs: [], // TODO allow passing? + headers: {}, + stepCompletionOrder: steps.map((step) => step.id), + stepState: mockStepState, + disableImmediateExecution: Boolean(options.disableImmediateExecution), + isFailureHandler: false, // TODO need to allow hitting an `onFailure` handler - not dynamically, but choosing it + timer: new ServerTiming(), + requestedRunStep: options.targetStepId, + transformCtx: this.options.transformCtx ?? mockCtx, + }, + }); + + const { ctx, ops, ...result } = await execution.start(); + + const mockState: InngestTestEngine.MockState = Object.keys(ops).reduce( + (acc, stepId) => { + const op = ops[stepId]; + + if (op?.seen === false || !op?.rawArgs) { + return acc; + } + + const mock = jest.fn(async (...args: unknown[]) => { + if ("error" in op) { + throw op.error; + } + + return op.data; + }); + + // execute it to show it was hit + mock(op.rawArgs); + + return { + ...acc, + [stepId]: mock, + }; + }, + {} as InngestTestEngine.MockState + ); + + // now proxy the mock state to always retrn some empty mock that hasn't been + // called for missing keys + const mockStateProxy = new Proxy(mockState, { + get(target, prop) { + if (prop in target) { + return target[prop as keyof typeof target]; + } + + return jest.fn(); + }, + }); + + const run = new InngestTestRun({ + testEngine: this.clone(options), + }); + + return { + result, + ctx: ctx as InngestTestEngine.MockContext, + state: mockStateProxy, + run, + }; + } +} diff --git a/packages/test/src/InngestTestRun.ts b/packages/test/src/InngestTestRun.ts new file mode 100644 index 000000000..2e640465a --- /dev/null +++ b/packages/test/src/InngestTestRun.ts @@ -0,0 +1,153 @@ +import { expect } from "@jest/globals"; +import type { + ExecutionResult, + ExecutionResults, +} from "inngest/components/execution/InngestExecution"; +import { createDeferredPromise } from "inngest/helpers/promises"; +import type { InngestTestEngine } from "./InngestTestEngine.js"; + +/** + * A test run that allows you to wait for specific checkpoints in a run that + * covers many executions. + * + * @TODO We may need to separate run execution by {@link ExecutionVersion}. + */ +export namespace InngestTestRun { + /** + * Options for creating a new {@link InngestTestRun} instance. + */ + export interface Options { + /** + * The test engine to use for running the function. + */ + testEngine: InngestTestEngine; + } + + /** + * The possible checkpoints that can be reached during a test run. + */ + export type CheckpointKey = ExecutionResult["type"]; + + /** + * A checkpoint that can be reached during a test run. + */ + export type Checkpoint = Omit< + Extract, + "ctx" | "ops" + >; +} + +/** + * A test run that allows you to wait for specific checkpoints in a run that + * covers many executions. + * + * @TODO We may need to separate run execution by {@link ExecutionVersion}. + */ +export class InngestTestRun { + public options: InngestTestRun.Options; + + constructor(options: InngestTestRun.Options) { + this.options = options; + } + + /** + * Keep executing the function until a specific checkpoint is reached. + * + * @TODO What if the thing we're waiting for has already happened? + */ + public async waitFor( + /** + * The checkpoint to wait for. + */ + checkpoint: T, + + /** + * An optional subset of the checkpoint to match against. Any checkpoint of + * this type will be matched. + * + * When providing a `subset`, use `expect` tooling such as + * `expect.stringContaining` to match partial values. + */ + subset?: Partial> + ): Promise> { + let finished = false; + const runningState: InngestTestEngine.InlineOptions = {}; + + const { promise, resolve } = + createDeferredPromise>(); + + const finish = (output: InngestTestEngine.ExecutionOutput) => { + finished = true; + resolve(output as InngestTestEngine.ExecutionOutput); + }; + + const processChain = async (targetStepId?: string) => { + if (finished) { + return; + } + + const exec = await this.options.testEngine.execute({ + ...runningState, + targetStepId, + }); + + if (exec.result.type === checkpoint) { + try { + if (subset) { + expect(exec.result).toMatchObject(subset); + } + + return finish(exec); + } catch (err) { + // noop + } + } + + const resultHandlers: Record void> = { + "function-resolved": () => finish(exec), + "function-rejected": () => finish(exec), + "step-not-found": () => { + processChain(); + }, + "steps-found": () => { + // run all + const result = + exec.result as InngestTestRun.Checkpoint<"steps-found">; + + if (result.steps.length > 1) { + runningState.disableImmediateExecution = true; + } + + result.steps.forEach((step) => { + processChain(step.id); + }); + }, + "step-ran": () => { + const result = exec.result as InngestTestRun.Checkpoint<"step-ran">; + + // add to our running state + runningState.steps ??= []; + runningState.steps.push({ + id: result.step.name as string, // TODO we need the non-hashed ID here, or a way to map it + handler: () => { + if (result.step.error) { + throw result.step.error; + } + + return result.step.data; + }, + }); + + processChain(); + }, + }; + + resultHandlers[exec.result.type](); + }; + + // kick off + processChain(); + + return promise; + } +} diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts new file mode 100644 index 000000000..1f285fae5 --- /dev/null +++ b/packages/test/src/index.ts @@ -0,0 +1,3 @@ +export * from "./InngestTestEngine"; +export * from "./InngestTestRun"; +export * from "./util"; diff --git a/packages/test/src/util.ts b/packages/test/src/util.ts new file mode 100644 index 000000000..541d21eec --- /dev/null +++ b/packages/test/src/util.ts @@ -0,0 +1,42 @@ +import { jest } from "@jest/globals"; +import { internalEvents } from "inngest"; +import type { Context, EventPayload } from "inngest/types"; +import { ulid } from "ulid"; + +/** + * The default context transformation function that mocks all step tools. Use + * this in addition to your custom transformation function if you'd like to keep + * this functionality. + */ +export const mockCtx = (ctx: Readonly): Context.Any => { + const step = Object.keys(ctx.step).reduce( + (acc, key) => { + const tool = ctx.step[key as keyof typeof ctx.step]; + const mock = jest.fn(tool); + + return { + ...acc, + [key]: mock, + }; + }, + {} as Context.Any["step"] + ); + + return { + ...ctx, + step, + }; +}; + +/** + * Creates a tiny mock invocation event used to replace or complement given + * event data. + */ +export const createMockEvent = () => { + return { + id: ulid(), + name: `${internalEvents.FunctionInvoked}`, + data: {}, + ts: Date.now(), + } satisfies EventPayload; +}; diff --git a/packages/test/tsconfig.build.json b/packages/test/tsconfig.build.json new file mode 100644 index 000000000..f19b0916b --- /dev/null +++ b/packages/test/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/test/tsconfig.json b/packages/test/tsconfig.json new file mode 100644 index 000000000..b93f8983c --- /dev/null +++ b/packages/test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "rootDir": "./src", + "declaration": true, + "sourceMap": true, + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["./src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26f7dd358..da26363d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,6 +300,16 @@ importers: specifier: ~5.4.0 version: 5.4.2 + packages/test: + dependencies: + ulid: + specifier: ^2.3.0 + version: 2.3.0 + devDependencies: + '@jest/globals': + specifier: ^29.5.0 + version: 29.5.0 + packages: '@aashutoshrathi/word-wrap@1.2.6': @@ -4643,6 +4653,10 @@ packages: ufo@1.3.0: resolution: {integrity: sha512-bRn3CsoojyNStCZe0BG0Mt4Nr/4KF+rhFlnNXybgqt5pXHNFRlqinSoQaTrGyzE4X8aHplSb+TorH+COin9Yxw==} + ulid@2.3.0: + resolution: {integrity: sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw==} + hasBin: true + unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} @@ -9847,6 +9861,8 @@ snapshots: ufo@1.3.0: {} + ulid@2.3.0: {} + unbox-primitive@1.0.2: dependencies: call-bind: 1.0.2