From ebef13f185a56c3587522dfa893effd5b841589f Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Sat, 7 Sep 2024 13:12:51 +0000 Subject: [PATCH 01/17] Reestablish build and release for `@inngest/test` --- packages/test/.gitignore | 1 + packages/test/CHANGELOG.md | 0 packages/test/package.json | 11 +++++-- pnpm-lock.yaml | 67 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 packages/test/CHANGELOG.md diff --git a/packages/test/.gitignore b/packages/test/.gitignore index de4d1f007..36f7a1030 100644 --- a/packages/test/.gitignore +++ b/packages/test/.gitignore @@ -1,2 +1,3 @@ dist node_modules +inngest-test.tgz diff --git a/packages/test/CHANGELOG.md b/packages/test/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/test/package.json b/packages/test/package.json index f413859e4..aec08b85e 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -9,10 +9,14 @@ }, "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" + "build:copy": "cp package.json LICENSE.md README.md CHANGELOG.md dist", + "pack": "pnpm run build && yarn pack --verbose --frozen-lockfile --filename inngest-test.tgz --cwd dist", + "postversion": "pnpm run build && pnpm run build:copy", + "release": "DIST_DIR=dist node ../../scripts/release/publish.js && pnpm dlx jsr publish --allow-slow-types --allow-dirty", + "release:version": "node ../../scripts/release/jsrVersion.js" }, "exports": { ".": { @@ -39,5 +43,8 @@ }, "dependencies": { "ulid": "^2.3.0" + }, + "peerDependencies": { + "inngest": "^3.22.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da26363d2..0b8a138a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,9 @@ importers: packages/test: dependencies: + inngest: + specifier: ^3.22.9 + version: 3.22.9(@sveltejs/kit@1.27.3)(@vercel/node@2.15.9)(aws-lambda@1.0.7)(express@4.19.2)(fastify@4.21.0)(h3@1.8.1)(hono@4.2.7)(koa@2.14.2)(next@13.5.4)(typescript@5.5.2) ulid: specifier: ^2.3.0 version: 2.3.0 @@ -2980,6 +2983,42 @@ packages: typescript: optional: true + inngest@3.22.9: + resolution: {integrity: sha512-ZD/Q450EU7LYlkd24uixWlyhX1/lTkXrWiOhyDGAF5ahKaaVXECOTSW3gCSV/340T7UxNefcwZK5T4BAzxcADw==} + engines: {node: '>=14'} + peerDependencies: + '@sveltejs/kit': '>=1.27.3' + '@vercel/node': '>=2.15.9' + aws-lambda: '>=1.0.7' + express: '>=4.19.2' + fastify: '>=4.21.0' + h3: '>=1.8.1' + hono: '>=4.2.7' + koa: '>=2.14.2' + next: '>=12.0.0' + typescript: '>=4.7.2' + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + '@vercel/node': + optional: true + aws-lambda: + optional: true + express: + optional: true + fastify: + optional: true + h3: + optional: true + hono: + optional: true + koa: + optional: true + next: + optional: true + typescript: + optional: true + inquirer@9.2.10: resolution: {integrity: sha512-tVVNFIXU8qNHoULiazz612GFl+yqNfjMTbLuViNJE/d860Qxrd3NMrse8dm40VUQLOQeULvaQF8lpAhvysjeyA==} engines: {node: '>=14.18.0'} @@ -8032,6 +8071,34 @@ snapshots: - encoding - supports-color + inngest@3.22.9(@sveltejs/kit@1.27.3)(@vercel/node@2.15.9)(aws-lambda@1.0.7)(express@4.19.2)(fastify@4.21.0)(h3@1.8.1)(hono@4.2.7)(koa@2.14.2)(next@13.5.4)(typescript@5.5.2): + dependencies: + '@types/debug': 4.1.12 + canonicalize: 1.0.8 + chalk: 4.1.2 + cross-fetch: 4.0.0 + debug: 4.3.4 + hash.js: 1.1.7 + json-stringify-safe: 5.0.1 + ms: 2.1.3 + serialize-error-cjs: 0.1.3 + strip-ansi: 5.2.0 + zod: 3.22.3 + optionalDependencies: + '@sveltejs/kit': 1.27.3 + '@vercel/node': 2.15.9 + aws-lambda: 1.0.7 + express: 4.19.2 + fastify: 4.21.0 + h3: 1.8.1 + hono: 4.2.7 + koa: 2.14.2 + next: 13.5.4(@babel/core@7.23.6)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + typescript: 5.5.2 + transitivePeerDependencies: + - encoding + - supports-color + inquirer@9.2.10: dependencies: '@ljharb/through': 2.3.9 From 4e2625c949d0a46f3c3b99fff3605c1df90df2fb Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Sat, 7 Sep 2024 15:07:54 +0000 Subject: [PATCH 02/17] Add `DeepPartial` type for shoehorning subset matching --- packages/test/src/util.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/test/src/util.ts b/packages/test/src/util.ts index 541d21eec..4881f502a 100644 --- a/packages/test/src/util.ts +++ b/packages/test/src/util.ts @@ -40,3 +40,10 @@ export const createMockEvent = () => { ts: Date.now(), } satisfies EventPayload; }; + +/** + * A deep partial, where every key of every object is optional. + */ +export type DeepPartial = { + [K in keyof T]?: T[K] extends object ? DeepPartial : T[K]; +}; From b25de92f4ce2c1ca8a2952c712f0882846f7dcfb Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Sat, 7 Sep 2024 15:08:43 +0000 Subject: [PATCH 03/17] Allow minimal matching when using `run.waitFor()` --- packages/test/src/InngestTestRun.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/test/src/InngestTestRun.ts b/packages/test/src/InngestTestRun.ts index 2e640465a..66e8658d3 100644 --- a/packages/test/src/InngestTestRun.ts +++ b/packages/test/src/InngestTestRun.ts @@ -5,6 +5,7 @@ import type { } from "inngest/components/execution/InngestExecution"; import { createDeferredPromise } from "inngest/helpers/promises"; import type { InngestTestEngine } from "./InngestTestEngine.js"; +import type { DeepPartial } from "./util"; /** * A test run that allows you to wait for specific checkpoints in a run that @@ -68,7 +69,7 @@ export class InngestTestRun { * When providing a `subset`, use `expect` tooling such as * `expect.stringContaining` to match partial values. */ - subset?: Partial> + subset?: DeepPartial> ): Promise> { let finished = false; const runningState: InngestTestEngine.InlineOptions = {}; From 4532ddb0009cd35e30d43217e0a2d2c49c433717 Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Sat, 7 Sep 2024 15:09:08 +0000 Subject: [PATCH 04/17] Sanitize provided subsets for matching to abstract ID hashing --- packages/test/src/InngestTestRun.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/test/src/InngestTestRun.ts b/packages/test/src/InngestTestRun.ts index 66e8658d3..c6ad585ce 100644 --- a/packages/test/src/InngestTestRun.ts +++ b/packages/test/src/InngestTestRun.ts @@ -3,6 +3,7 @@ import type { ExecutionResult, ExecutionResults, } from "inngest/components/execution/InngestExecution"; +import { _internals } from "inngest/components/execution/v1"; import { createDeferredPromise } from "inngest/helpers/promises"; import type { InngestTestEngine } from "./InngestTestEngine.js"; import type { DeepPartial } from "./util"; @@ -82,6 +83,22 @@ export class InngestTestRun { resolve(output as InngestTestEngine.ExecutionOutput); }; + /** + * Make sure we sanitize any given ID to prehash it for the user. This is + * abstracted from the user entirely so they shouldn't be expected to be + * providing hashes. + */ + const sanitizedSubset: typeof subset = subset && { + ...subset, + ...("step" in subset && + typeof subset.step === "object" && + subset.step !== null && + "id" in subset.step && + typeof subset.step.id === "string" && { + step: { ...subset.step, id: _internals.hashId(subset.step.id) }, + }), + }; + const processChain = async (targetStepId?: string) => { if (finished) { return; @@ -94,8 +111,8 @@ export class InngestTestRun { if (exec.result.type === checkpoint) { try { - if (subset) { - expect(exec.result).toMatchObject(subset); + if (sanitizedSubset) { + expect(exec.result).toMatchObject(sanitizedSubset); } return finish(exec); From a0004d1cd6e409827219de8960e7f5e5bab99e3e Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Sat, 7 Sep 2024 15:09:24 +0000 Subject: [PATCH 05/17] Fix incorrect checkpoints being returned during testing --- packages/test/src/InngestTestRun.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/test/src/InngestTestRun.ts b/packages/test/src/InngestTestRun.ts index c6ad585ce..4f968122f 100644 --- a/packages/test/src/InngestTestRun.ts +++ b/packages/test/src/InngestTestRun.ts @@ -75,11 +75,20 @@ export class InngestTestRun { let finished = false; const runningState: InngestTestEngine.InlineOptions = {}; - const { promise, resolve } = + const { promise, resolve, reject } = createDeferredPromise>(); const finish = (output: InngestTestEngine.ExecutionOutput) => { finished = true; + + if (output.result.type !== checkpoint) { + reject( + new Error( + `Expected checkpoint "${checkpoint}" but got "${output.result.type}"` + ) + ); + } + resolve(output as InngestTestEngine.ExecutionOutput); }; From 8040933faeabf1bd80151bfb23315e6d3f1323f8 Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Sat, 7 Sep 2024 15:09:52 +0000 Subject: [PATCH 06/17] Refactor `t.executeAndWaitFor()` to allow more friendly API --- packages/test/src/InngestTestEngine.ts | 34 ++++++++++++++++---------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/test/src/InngestTestEngine.ts b/packages/test/src/InngestTestEngine.ts index 251356e67..a1c552423 100644 --- a/packages/test/src/InngestTestEngine.ts +++ b/packages/test/src/InngestTestEngine.ts @@ -11,7 +11,7 @@ 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"; +import { createMockEvent, mockCtx, type DeepPartial } from "./util.js"; /** * A test engine for running Inngest functions in a test environment, providing @@ -110,6 +110,23 @@ export namespace InngestTestEngine { */ export type InlineOptions = Omit; + /** + * Options that can be passed to an initial execution that then waits for a + * particular checkpoint to occur. + */ + export type ExecuteAndWaitForOptions< + T extends InngestTestRun.CheckpointKey = InngestTestRun.CheckpointKey, + > = InlineOptions & { + /** + * 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?: DeepPartial>; + }; + /** * A mocked state object that allows you to assert step usage, input, and * output. @@ -186,28 +203,19 @@ export class InngestTestEngine { * 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. + * Options and state to start the run with. */ - subset?: Partial> + inlineOpts?: InngestTestEngine.ExecuteAndWaitForOptions ): Promise> { const { run } = await this.execute(inlineOpts); - return run.waitFor(checkpoint, subset); + return run.waitFor(checkpoint, inlineOpts?.subset); } /** From b2d3c9f40d7549d5de0fccda7cc2f9a3bd2af481 Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Thu, 12 Sep 2024 11:59:53 +0000 Subject: [PATCH 07/17] Remove `@jest/globals`; remove immediate mocking --- packages/test/package.json | 3 - packages/test/src/InngestTestEngine.ts | 104 +++++++++++++------------ packages/test/src/InngestTestRun.ts | 15 +--- packages/test/src/util.ts | 68 ++++++++++++---- pnpm-lock.yaml | 43 +++++++--- 5 files changed, 141 insertions(+), 92 deletions(-) diff --git a/packages/test/package.json b/packages/test/package.json index aec08b85e..72b179b2b 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -38,9 +38,6 @@ }, "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 index a1c552423..470909d1b 100644 --- a/packages/test/src/InngestTestEngine.ts +++ b/packages/test/src/InngestTestEngine.ts @@ -1,4 +1,3 @@ -import { jest } from "@jest/globals"; import { ExecutionVersion, type MemoizedOp, @@ -98,11 +97,11 @@ export namespace InngestTestEngine { * 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; - }; - } + // export interface MockContext extends Omit { + // step: { + // [K in keyof Context.Any["step"]]: MockedFunction; + // }; + // } /** * Options that can be passed to an existing execution or run to continue @@ -115,7 +114,7 @@ export namespace InngestTestEngine { * particular checkpoint to occur. */ export type ExecuteAndWaitForOptions< - T extends InngestTestRun.CheckpointKey = InngestTestRun.CheckpointKey, + T extends InngestTestRun.CheckpointKey = InngestTestRun.CheckpointKey > = InlineOptions & { /** * An optional subset of the checkpoint to match against. Any checkpoint of @@ -133,14 +132,15 @@ export namespace InngestTestEngine { */ export type MockState = Record< string, - jest.Mock<(...args: unknown[]) => Promise> + // MockedFunction<(...args: unknown[]) => Promise> + unknown >; /** * The output of an individual function execution. */ export interface ExecutionOutput< - T extends InngestTestRun.CheckpointKey = InngestTestRun.CheckpointKey, + T extends InngestTestRun.CheckpointKey = InngestTestRun.CheckpointKey > { /** * The result of the execution. @@ -153,13 +153,14 @@ export namespace InngestTestEngine { * * @TODO This type may vary is `transformCtx` is given. */ - ctx: InngestTestEngine.MockContext; + // ctx: InngestTestEngine.MockContext; + ctx: Context.Any; /** * The mocked state object that allows you to assert step usage, input, and * output. */ - state: InngestTestEngine.MockState; + // state: InngestTestEngine.MockState; /** * An {@link InngestTestRun} instance that allows you to wait for specific @@ -322,44 +323,44 @@ export class InngestTestEngine { 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 mockState: InngestTestEngine.MockState = Object.keys(ops).reduce( + // (acc, stepId) => { + // const op = ops[stepId]; + + // if (op?.seen === false || !op?.rawArgs) { + // return acc; + // } + + // const mock = mockFn(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 mockFn(); + // }, + // }); const run = new InngestTestRun({ testEngine: this.clone(options), @@ -367,8 +368,9 @@ export class InngestTestEngine { return { result, - ctx: ctx as InngestTestEngine.MockContext, - state: mockStateProxy, + // ctx: ctx as InngestTestEngine.MockContext + ctx, + // state: mockStateProxy, run, }; } diff --git a/packages/test/src/InngestTestRun.ts b/packages/test/src/InngestTestRun.ts index 4f968122f..f258706bb 100644 --- a/packages/test/src/InngestTestRun.ts +++ b/packages/test/src/InngestTestRun.ts @@ -1,4 +1,3 @@ -import { expect } from "@jest/globals"; import type { ExecutionResult, ExecutionResults, @@ -6,7 +5,7 @@ import type { import { _internals } from "inngest/components/execution/v1"; import { createDeferredPromise } from "inngest/helpers/promises"; import type { InngestTestEngine } from "./InngestTestEngine.js"; -import type { DeepPartial } from "./util"; +import { isDeeplyEqual, type DeepPartial } from "./util"; /** * A test run that allows you to wait for specific checkpoints in a run that @@ -118,16 +117,8 @@ export class InngestTestRun { targetStepId, }); - if (exec.result.type === checkpoint) { - try { - if (sanitizedSubset) { - expect(exec.result).toMatchObject(sanitizedSubset); - } - - return finish(exec); - } catch (err) { - // noop - } + if (exec.result.type === checkpoint && (!sanitizedSubset || isDeeplyEqual(sanitizedSubset, exec.result))) { + return finish(exec) } const resultHandlers: Record void> = { diff --git a/packages/test/src/util.ts b/packages/test/src/util.ts index 4881f502a..8ec5a867f 100644 --- a/packages/test/src/util.ts +++ b/packages/test/src/util.ts @@ -1,4 +1,3 @@ -import { jest } from "@jest/globals"; import { internalEvents } from "inngest"; import type { Context, EventPayload } from "inngest/types"; import { ulid } from "ulid"; @@ -9,23 +8,33 @@ import { ulid } from "ulid"; * 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"] - ); + // const step = Object.keys(ctx.step).reduce( + // (acc, key) => { + // const tool = ctx.step[key as keyof typeof ctx.step]; + // // const mock = mockFn(tool); - return { - ...ctx, - step, - }; + // class FunctionWrapper extends Function { + // constructor(...args: any[]) { + // return mockFn(tool).apply(this, args); + // } + // } + + // const mock = FunctionWrapper; + // // const mock2 = jest.fn(tool); + + // return { + // ...acc, + // [key]: mock, + // }; + // }, + // {} as Context.Any["step"] + // ); + + // return { + // ...ctx, + // step, + // }; + return ctx; }; /** @@ -47,3 +56,28 @@ export const createMockEvent = () => { export type DeepPartial = { [K in keyof T]?: T[K] extends object ? DeepPartial : T[K]; }; + +/** + * Ensures that all keys in the subset are present in the actual object and that + * the values match. + */ +export const isDeeplyEqual = ( + subset: DeepPartial, + actual: T +): boolean => { + return Object.keys(subset).every((key) => { + const subsetValue = subset[key as keyof T]; + const actualValue = actual[key as keyof T]; + + if ( + typeof subsetValue === "object" && + subsetValue !== null && + typeof actualValue === "object" && + actualValue !== null + ) { + return isDeeplyEqual(subsetValue, actualValue); + } + + return subsetValue === actualValue; + }); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b8a138a8..147eb614f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,7 +44,7 @@ importers: version: 29.5.0(@types/node@18.16.16)(ts-node@10.9.1(@types/node@18.16.16)(typescript@5.5.2)) ts-jest: specifier: ^29.1.0 - version: 29.1.0(@babel/core@7.23.6)(@jest/types@29.5.0)(babel-jest@29.5.0(@babel/core@7.23.6))(jest@29.5.0(@types/node@18.16.16)(ts-node@10.9.1(@types/node@18.16.16)(typescript@5.5.2)))(typescript@5.5.2) + version: 29.1.0(@babel/core@7.23.6)(@jest/types@29.6.3)(babel-jest@29.5.0(@babel/core@7.23.6))(jest@29.5.0(@types/node@18.16.16)(ts-node@10.9.1(@types/node@18.16.16)(typescript@5.5.2)))(typescript@5.5.2) packages/eslint-plugin-internal: dependencies: @@ -225,7 +225,7 @@ importers: version: 0.3.4 ts-jest: specifier: ^29.1.0 - version: 29.1.0(@babel/core@7.23.6)(@jest/types@29.5.0)(babel-jest@29.5.0(@babel/core@7.23.6))(jest@29.5.0(@types/node@18.16.16)(ts-node@10.9.1(@types/node@18.16.16)(typescript@5.5.2)))(typescript@5.5.2) + version: 29.1.0(@babel/core@7.23.6)(@jest/types@29.6.3)(babel-jest@29.5.0(@babel/core@7.23.6))(jest@29.5.0(@types/node@18.16.16)(ts-node@10.9.1(@types/node@18.16.16)(typescript@5.5.2)))(typescript@5.5.2) tsx: specifier: ^3.12.7 version: 3.12.7 @@ -277,7 +277,7 @@ importers: version: 29.5.0(@types/node@18.16.16)(ts-node@10.9.1(@types/node@18.16.16)(typescript@5.5.2)) ts-jest: specifier: ^29.1.0 - version: 29.1.0(@babel/core@7.23.6)(@jest/types@29.5.0)(babel-jest@29.5.0(@babel/core@7.23.6))(jest@29.5.0(@types/node@18.16.16)(ts-node@10.9.1(@types/node@18.16.16)(typescript@5.5.2)))(typescript@5.5.2) + version: 29.1.0(@babel/core@7.23.6)(@jest/types@29.6.3)(babel-jest@29.5.0(@babel/core@7.23.6))(jest@29.5.0(@types/node@18.16.16)(ts-node@10.9.1(@types/node@18.16.16)(typescript@5.5.2)))(typescript@5.5.2) typescript: specifier: ~5.5.2 version: 5.5.2 @@ -308,10 +308,6 @@ importers: ulid: specifier: ^2.3.0 version: 2.3.0 - devDependencies: - '@jest/globals': - specifier: ^29.5.0 - version: 29.5.0 packages: @@ -1011,6 +1007,10 @@ packages: resolution: {integrity: sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/source-map@29.4.3': resolution: {integrity: sha512-qyt/mb6rLyd9j1jUts4EQncvS6Yy3PM9HghnNv86QBlV+zdL2inCdK1tuVlL+J+lpiw2BI67qXOrX3UurBqQ1w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1031,6 +1031,10 @@ packages: resolution: {integrity: sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jridgewell/gen-mapping@0.3.3': resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} engines: {node: '>=6.0.0'} @@ -1203,6 +1207,9 @@ packages: '@sinclair/typebox@0.25.24': resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==} + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@sinonjs/commons@2.0.0': resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} @@ -5724,6 +5731,11 @@ snapshots: dependencies: '@sinclair/typebox': 0.25.24 + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + optional: true + '@jest/source-map@29.4.3': dependencies: '@jridgewell/trace-mapping': 0.3.20 @@ -5773,6 +5785,16 @@ snapshots: '@types/yargs': 17.0.23 chalk: 4.1.2 + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.4 + '@types/istanbul-reports': 3.0.1 + '@types/node': 18.16.16 + '@types/yargs': 17.0.23 + chalk: 4.1.2 + optional: true + '@jridgewell/gen-mapping@0.3.3': dependencies: '@jridgewell/set-array': 1.1.2 @@ -5942,6 +5964,9 @@ snapshots: '@sinclair/typebox@0.25.24': {} + '@sinclair/typebox@0.27.8': + optional: true + '@sinonjs/commons@2.0.0': dependencies: type-detect: 4.0.8 @@ -9788,7 +9813,7 @@ snapshots: dependencies: typescript: 5.5.2 - ts-jest@29.1.0(@babel/core@7.23.6)(@jest/types@29.5.0)(babel-jest@29.5.0(@babel/core@7.23.6))(jest@29.5.0(@types/node@18.16.16)(ts-node@10.9.1(@types/node@18.16.16)(typescript@5.5.2)))(typescript@5.5.2): + ts-jest@29.1.0(@babel/core@7.23.6)(@jest/types@29.6.3)(babel-jest@29.5.0(@babel/core@7.23.6))(jest@29.5.0(@types/node@18.16.16)(ts-node@10.9.1(@types/node@18.16.16)(typescript@5.5.2)))(typescript@5.5.2): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -9802,7 +9827,7 @@ snapshots: yargs-parser: 21.1.1 optionalDependencies: '@babel/core': 7.23.6 - '@jest/types': 29.5.0 + '@jest/types': 29.6.3 babel-jest: 29.5.0(@babel/core@7.23.6) ts-morph@12.0.0: From 409b524e813e29e67a5d29383c92ad7d04d88fe0 Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:30:57 +0000 Subject: [PATCH 08/17] Make sure we have `prettier` --- packages/test/package.json | 3 +++ packages/test/src/InngestTestEngine.ts | 4 ++-- packages/test/src/InngestTestRun.ts | 7 +++++-- pnpm-lock.yaml | 4 ++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/test/package.json b/packages/test/package.json index 72b179b2b..1d01302d5 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -43,5 +43,8 @@ }, "peerDependencies": { "inngest": "^3.22.9" + }, + "devDependencies": { + "prettier": "^3.1.0" } } diff --git a/packages/test/src/InngestTestEngine.ts b/packages/test/src/InngestTestEngine.ts index 470909d1b..dcaa14b40 100644 --- a/packages/test/src/InngestTestEngine.ts +++ b/packages/test/src/InngestTestEngine.ts @@ -114,7 +114,7 @@ export namespace InngestTestEngine { * particular checkpoint to occur. */ export type ExecuteAndWaitForOptions< - T extends InngestTestRun.CheckpointKey = InngestTestRun.CheckpointKey + T extends InngestTestRun.CheckpointKey = InngestTestRun.CheckpointKey, > = InlineOptions & { /** * An optional subset of the checkpoint to match against. Any checkpoint of @@ -140,7 +140,7 @@ export namespace InngestTestEngine { * The output of an individual function execution. */ export interface ExecutionOutput< - T extends InngestTestRun.CheckpointKey = InngestTestRun.CheckpointKey + T extends InngestTestRun.CheckpointKey = InngestTestRun.CheckpointKey, > { /** * The result of the execution. diff --git a/packages/test/src/InngestTestRun.ts b/packages/test/src/InngestTestRun.ts index f258706bb..97b6af844 100644 --- a/packages/test/src/InngestTestRun.ts +++ b/packages/test/src/InngestTestRun.ts @@ -117,8 +117,11 @@ export class InngestTestRun { targetStepId, }); - if (exec.result.type === checkpoint && (!sanitizedSubset || isDeeplyEqual(sanitizedSubset, exec.result))) { - return finish(exec) + if ( + exec.result.type === checkpoint && + (!sanitizedSubset || isDeeplyEqual(sanitizedSubset, exec.result)) + ) { + return finish(exec); } const resultHandlers: Record void> = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 147eb614f..16bcc9f51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -308,6 +308,10 @@ importers: ulid: specifier: ^2.3.0 version: 2.3.0 + devDependencies: + prettier: + specifier: ^3.1.0 + version: 3.1.0 packages: From 40d15f99be59554204a1ed66fbcf267de9ff0dcc Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:32:29 +0000 Subject: [PATCH 09/17] Log the result when encountering an unexpected checkpoint --- packages/test/src/InngestTestRun.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/test/src/InngestTestRun.ts b/packages/test/src/InngestTestRun.ts index 97b6af844..e32589a7d 100644 --- a/packages/test/src/InngestTestRun.ts +++ b/packages/test/src/InngestTestRun.ts @@ -83,7 +83,9 @@ export class InngestTestRun { if (output.result.type !== checkpoint) { reject( new Error( - `Expected checkpoint "${checkpoint}" but got "${output.result.type}"` + `Expected checkpoint "${checkpoint}" but got "${ + output.result.type + }": ${JSON.stringify(output.result, null, 2)}` ) ); } From b5c5580b5de0c2ace1075d44064d7d6e8d36ef98 Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Thu, 12 Sep 2024 17:03:35 +0000 Subject: [PATCH 10/17] Try to re-add automatic mocks for `ctx.step` --- packages/test/package.json | 1 + packages/test/src/InngestTestEngine.ts | 102 ++-- packages/test/src/spy.ts | 625 +++++++++++++++++++++++++ packages/test/src/util.ts | 41 +- packages/test/tsconfig.json | 5 +- pnpm-lock.yaml | 9 + 6 files changed, 706 insertions(+), 77 deletions(-) create mode 100644 packages/test/src/spy.ts diff --git a/packages/test/package.json b/packages/test/package.json index 1d01302d5..c132633cc 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -39,6 +39,7 @@ "author": "Jack Williams ", "license": "Apache-2.0", "dependencies": { + "tinyspy": "^3.0.2", "ulid": "^2.3.0" }, "peerDependencies": { diff --git a/packages/test/src/InngestTestEngine.ts b/packages/test/src/InngestTestEngine.ts index dcaa14b40..a13e9aedd 100644 --- a/packages/test/src/InngestTestEngine.ts +++ b/packages/test/src/InngestTestEngine.ts @@ -10,6 +10,7 @@ import { ServerTiming } from "inngest/helpers/ServerTiming"; import { Context, EventPayload } from "inngest/types"; import { ulid } from "ulid"; import { InngestTestRun } from "./InngestTestRun.js"; +import { Mock, fn as mockFn } from "./spy.js"; import { createMockEvent, mockCtx, type DeepPartial } from "./util.js"; /** @@ -97,11 +98,11 @@ export namespace InngestTestEngine { * 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"]]: MockedFunction; - // }; - // } + export interface MockContext extends Omit { + step: { + [K in keyof Context.Any["step"]]: Mock; + }; + } /** * Options that can be passed to an existing execution or run to continue @@ -132,8 +133,7 @@ export namespace InngestTestEngine { */ export type MockState = Record< string, - // MockedFunction<(...args: unknown[]) => Promise> - unknown + Mock<(...args: unknown[]) => Promise> >; /** @@ -153,14 +153,13 @@ export namespace InngestTestEngine { * * @TODO This type may vary is `transformCtx` is given. */ - // ctx: InngestTestEngine.MockContext; - ctx: Context.Any; + ctx: InngestTestEngine.MockContext; /** * The mocked state object that allows you to assert step usage, input, and * output. */ - // state: InngestTestEngine.MockState; + state: InngestTestEngine.MockState; /** * An {@link InngestTestRun} instance that allows you to wait for specific @@ -323,44 +322,46 @@ export class InngestTestEngine { 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 = mockFn(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 mockFn(); - // }, - // }); + const mockState: InngestTestEngine.MockState = await Object.keys( + ops + ).reduce( + async (acc, stepId) => { + const op = ops[stepId]; + + if (op?.seen === false || !op?.rawArgs) { + return acc; + } + + const mock = await mockFn(async (...args: unknown[]) => { + if ("error" in op) { + throw op.error; + } + + return op.data; + }); + + // execute it to show it was hit + mock(op.rawArgs); + + return { + ...(await acc), + [stepId]: mock, + }; + }, + Promise.resolve({}) as Promise + ); + + // 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 mockFn(); + }, + }); const run = new InngestTestRun({ testEngine: this.clone(options), @@ -368,9 +369,8 @@ export class InngestTestEngine { return { result, - // ctx: ctx as InngestTestEngine.MockContext - ctx, - // state: mockStateProxy, + ctx: ctx as InngestTestEngine.MockContext, + state: mockStateProxy, run, }; } diff --git a/packages/test/src/spy.ts b/packages/test/src/spy.ts new file mode 100644 index 000000000..dd4dc0a2d --- /dev/null +++ b/packages/test/src/spy.ts @@ -0,0 +1,625 @@ +import type { SpyInternalImpl } from "tinyspy"; +import * as tinyspy from "tinyspy"; + +interface MockResultReturn { + type: "return"; + /** + * The value that was returned from the function. If function returned a Promise, then this will be a resolved value. + */ + value: T; +} +interface MockResultIncomplete { + type: "incomplete"; + value: undefined; +} +interface MockResultThrow { + type: "throw"; + /** + * An error that was thrown during function execution. + */ + value: any; +} + +interface MockSettledResultFulfilled { + type: "fulfilled"; + value: T; +} + +interface MockSettledResultRejected { + type: "rejected"; + value: any; +} + +export type MockResult = + | MockResultReturn + | MockResultThrow + | MockResultIncomplete; +export type MockSettledResult = + | MockSettledResultFulfilled + | MockSettledResultRejected; + +export interface MockContext { + /** + * This is an array containing all arguments for each call. One item of the array is the arguments of that call. + * + * @example + * const fn = vi.fn() + * + * fn('arg1', 'arg2') + * fn('arg3') + * + * fn.mock.calls === [ + * ['arg1', 'arg2'], // first call + * ['arg3'], // second call + * ] + */ + calls: Parameters[]; + /** + * This is an array containing all instances that were instantiated when mock was called with a `new` keyword. Note that this is an actual context (`this`) of the function, not a return value. + */ + instances: ReturnType[]; + /** + * An array of `this` values that were used during each call to the mock function. + */ + contexts: ThisParameterType[]; + /** + * The order of mock's execution. This returns an array of numbers which are shared between all defined mocks. + * + * @example + * const fn1 = vi.fn() + * const fn2 = vi.fn() + * + * fn1() + * fn2() + * fn1() + * + * fn1.mock.invocationCallOrder === [1, 3] + * fn2.mock.invocationCallOrder === [2] + */ + invocationCallOrder: number[]; + /** + * This is an array containing all values that were `returned` from the function. + * + * The `value` property contains the returned value or thrown error. If the function returned a `Promise`, then `result` will always be `'return'` even if the promise was rejected. + * + * @example + * const fn = vi.fn() + * .mockReturnValueOnce('result') + * .mockImplementationOnce(() => { throw new Error('thrown error') }) + * + * const result = fn() + * + * try { + * fn() + * } + * catch {} + * + * fn.mock.results === [ + * { + * type: 'return', + * value: 'result', + * }, + * { + * type: 'throw', + * value: Error, + * }, + * ] + */ + results: MockResult>[]; + /** + * An array containing all values that were `resolved` or `rejected` from the function. + * + * This array will be empty if the function was never resolved or rejected. + * + * @example + * const fn = vi.fn().mockResolvedValueOnce('result') + * + * const result = fn() + * + * fn.mock.settledResults === [] + * fn.mock.results === [ + * { + * type: 'return', + * value: Promise<'result'>, + * }, + * ] + * + * await result + * + * fn.mock.settledResults === [ + * { + * type: 'fulfilled', + * value: 'result', + * }, + * ] + */ + settledResults: MockSettledResult>>[]; + /** + * This contains the arguments of the last call. If spy wasn't called, will return `undefined`. + */ + lastCall: Parameters | undefined; +} + +type Procedure = (...args: any[]) => any; +// pick a single function type from function overloads, unions, etc... +type NormalizedPrecedure = ( + ...args: Parameters +) => ReturnType; + +type Methods = keyof { + [K in keyof T as T[K] extends Procedure ? K : never]: T[K]; +}; +type Properties = { + [K in keyof T]: T[K] extends Procedure ? never : K; +}[keyof T] & + (string | symbol); +type Classes = { + [K in keyof T]: T[K] extends new (...args: any[]) => any ? K : never; +}[keyof T] & + (string | symbol); + +/* +cf. https://typescript-eslint.io/rules/method-signature-style/ + +Typescript assignability is different between + { foo: (f: T) => U } (this is "method-signature-style") +and + { foo(f: T): U } + +Jest uses the latter for `MockInstance.mockImplementation` etc... and it allows assignment such as: + const boolFn: Jest.Mock<() => boolean> = jest.fn<() => true>(() => true) +*/ +/* eslint-disable ts/method-signature-style */ +export interface MockInstance { + /** + * Use it to return the name given to mock with method `.mockName(name)`. + */ + getMockName(): string; + /** + * Sets internal mock name. Useful to see the name of the mock if an assertion fails. + */ + mockName(n: string): this; + /** + * Current context of the mock. It stores information about all invocation calls, instances, and results. + */ + mock: MockContext; + /** + * Clears all information about every call. After calling it, all properties on `.mock` will return an empty state. This method does not reset implementations. + * + * It is useful if you need to clean up mock between different assertions. + */ + mockClear(): this; + /** + * Does what `mockClear` does and makes inner implementation an empty function (returning `undefined` when invoked). This also resets all "once" implementations. + * + * This is useful when you want to completely reset a mock to the default state. + */ + mockReset(): this; + /** + * Does what `mockReset` does and restores inner implementation to the original function. + * + * Note that restoring mock from `vi.fn()` will set implementation to an empty function that returns `undefined`. Restoring a `vi.fn(impl)` will restore implementation to `impl`. + */ + mockRestore(): void; + /** + * Returns current mock implementation if there is one. + * + * If mock was created with `vi.fn`, it will consider passed down method as a mock implementation. + * + * If mock was created with `vi.spyOn`, it will return `undefined` unless a custom implementation was provided. + */ + getMockImplementation(): NormalizedPrecedure | undefined; + /** + * Accepts a function that will be used as an implementation of the mock. + * @example + * const increment = vi.fn().mockImplementation(count => count + 1); + * expect(increment(3)).toBe(4); + */ + mockImplementation(fn: NormalizedPrecedure): this; + /** + * Accepts a function that will be used as a mock implementation during the next call. Can be chained so that multiple function calls produce different results. + * @example + * const fn = vi.fn(count => count).mockImplementationOnce(count => count + 1); + * expect(fn(3)).toBe(4); + * expect(fn(3)).toBe(3); + */ + mockImplementationOnce(fn: NormalizedPrecedure): this; + /** + * Overrides the original mock implementation temporarily while the callback is being executed. + * @example + * const myMockFn = vi.fn(() => 'original') + * + * myMockFn.withImplementation(() => 'temp', () => { + * myMockFn() // 'temp' + * }) + * + * myMockFn() // 'original' + */ + withImplementation( + fn: NormalizedPrecedure, + cb: () => T2 + ): T2 extends Promise ? Promise : this; + + /** + * Use this if you need to return `this` context from the method without invoking actual implementation. + */ + mockReturnThis(): this; + /** + * Accepts a value that will be returned whenever the mock function is called. + */ + mockReturnValue(obj: ReturnType): this; + /** + * Accepts a value that will be returned during the next function call. If chained, every consecutive call will return the specified value. + * + * When there are no more `mockReturnValueOnce` values to use, mock will fallback to the previously defined implementation if there is one. + * @example + * const myMockFn = vi + * .fn() + * .mockReturnValue('default') + * .mockReturnValueOnce('first call') + * .mockReturnValueOnce('second call') + * + * // 'first call', 'second call', 'default' + * console.log(myMockFn(), myMockFn(), myMockFn()) + */ + mockReturnValueOnce(obj: ReturnType): this; + /** + * Accepts a value that will be resolved when async function is called. + * @example + * const asyncMock = vi.fn().mockResolvedValue(42) + * asyncMock() // Promise<42> + */ + mockResolvedValue(obj: Awaited>): this; + /** + * Accepts a value that will be resolved during the next function call. If chained, every consecutive call will resolve specified value. + * @example + * const myMockFn = vi + * .fn() + * .mockResolvedValue('default') + * .mockResolvedValueOnce('first call') + * .mockResolvedValueOnce('second call') + * + * // Promise<'first call'>, Promise<'second call'>, Promise<'default'> + * console.log(myMockFn(), myMockFn(), myMockFn()) + */ + mockResolvedValueOnce(obj: Awaited>): this; + /** + * Accepts an error that will be rejected when async function is called. + * @example + * const asyncMock = vi.fn().mockRejectedValue(new Error('Async error')) + * await asyncMock() // throws 'Async error' + */ + mockRejectedValue(obj: any): this; + /** + * Accepts a value that will be rejected during the next function call. If chained, every consecutive call will reject specified value. + * @example + * const asyncMock = vi + * .fn() + * .mockResolvedValueOnce('first call') + * .mockRejectedValueOnce(new Error('Async error')) + * + * await asyncMock() // first call + * await asyncMock() // throws "Async error" + */ + mockRejectedValueOnce(obj: any): this; +} +/* eslint-enable ts/method-signature-style */ + +export interface Mock extends MockInstance { + new (...args: Parameters): ReturnType; + (...args: Parameters): ReturnType; +} + +type PartialMaybePromise = T extends Promise> + ? Promise>> + : Partial; + +export interface PartialMock + extends MockInstance< + (...args: Parameters) => PartialMaybePromise> + > { + new (...args: Parameters): ReturnType; + (...args: Parameters): ReturnType; +} + +export type MaybeMockedConstructor = T extends new ( + ...args: Array +) => infer R + ? Mock<(...args: ConstructorParameters) => R> + : T; +export type MockedFunction = Mock & { + [K in keyof T]: T[K]; +}; +export type PartiallyMockedFunction = PartialMock & { + [K in keyof T]: T[K]; +}; +export type MockedFunctionDeep = Mock & + MockedObjectDeep; +export type PartiallyMockedFunctionDeep = PartialMock & + MockedObjectDeep; +export type MockedObject = MaybeMockedConstructor & { + [K in Methods]: T[K] extends Procedure ? MockedFunction : T[K]; +} & { [K in Properties]: T[K] }; +export type MockedObjectDeep = MaybeMockedConstructor & { + [K in Methods]: T[K] extends Procedure ? MockedFunctionDeep : T[K]; +} & { [K in Properties]: MaybeMockedDeep }; + +export type MaybeMockedDeep = T extends Procedure + ? MockedFunctionDeep + : T extends object + ? MockedObjectDeep + : T; + +export type MaybePartiallyMockedDeep = T extends Procedure + ? PartiallyMockedFunctionDeep + : T extends object + ? MockedObjectDeep + : T; + +export type MaybeMocked = T extends Procedure + ? MockedFunction + : T extends object + ? MockedObject + : T; + +export type MaybePartiallyMocked = T extends Procedure + ? PartiallyMockedFunction + : T extends object + ? MockedObject + : T; + +interface Constructable { + new (...args: any[]): any; +} + +export type MockedClass = MockInstance< + (...args: ConstructorParameters) => InstanceType +> & { + prototype: T extends { prototype: any } ? Mocked : never; +} & T; + +export type Mocked = { + [P in keyof T]: T[P] extends Procedure + ? MockInstance + : T[P] extends Constructable + ? MockedClass + : T[P]; +} & T; + +export const mocks: Set = new Set(); + +export function isMockFunction(fn: any): fn is MockInstance { + return ( + typeof fn === "function" && "_isMockFunction" in fn && fn._isMockFunction + ); +} + +export function spyOn>>( + obj: T, + methodName: S, + accessType: "get" +): MockInstance<() => T[S]>; +export function spyOn>>( + obj: T, + methodName: G, + accessType: "set" +): MockInstance<(arg: T[G]) => void>; +export function spyOn> | Methods>>( + obj: T, + methodName: M +): Required[M] extends { new (...args: infer A): infer R } + ? MockInstance<(this: R, ...args: A) => R> + : T[M] extends Procedure + ? MockInstance + : never; +export function spyOn( + obj: T, + method: K, + accessType?: "get" | "set" +): MockInstance { + const dictionary = { + get: "getter", + set: "setter", + } as const; + const objMethod = accessType ? { [dictionary[accessType]]: method } : method; + + const stub = tinyspy.internalSpyOn(obj, objMethod as any); + + return enhanceSpy(stub) as MockInstance; +} + +let callOrder = 0; + +function enhanceSpy( + spy: SpyInternalImpl, ReturnType> +): MockInstance { + type TArgs = Parameters; + type TReturns = ReturnType; + + const stub = spy as unknown as MockInstance; + + let implementation: T | undefined; + + let instances: any[] = []; + let contexts: any[] = []; + let invocations: number[] = []; + + const state = tinyspy.getInternalState(spy); + + const mockContext: MockContext = { + get calls() { + return state.calls; + }, + get contexts() { + return contexts; + }, + get instances() { + return instances; + }, + get invocationCallOrder() { + return invocations; + }, + get results() { + return state.results.map(([callType, value]) => { + const type = + callType === "error" ? ("throw" as const) : ("return" as const); + return { type, value }; + }); + }, + get settledResults() { + return state.resolves.map(([callType, value]) => { + const type = + callType === "error" ? ("rejected" as const) : ("fulfilled" as const); + return { type, value }; + }); + }, + get lastCall() { + return state.calls[state.calls.length - 1]; + }, + }; + + let onceImplementations: ((...args: TArgs) => TReturns)[] = []; + let implementationChangedTemporarily = false; + + function mockCall(this: unknown, ...args: any) { + instances.push(this); + contexts.push(this); + invocations.push(++callOrder); + const impl = implementationChangedTemporarily + ? implementation! + : onceImplementations.shift() || + implementation || + state.getOriginal() || + (() => {}); + return impl.apply(this, args); + } + + let name: string = (stub as any).name; + + stub.getMockName = () => name || "vi.fn()"; + stub.mockName = (n) => { + name = n; + return stub; + }; + + stub.mockClear = () => { + state.reset(); + instances = []; + contexts = []; + invocations = []; + return stub; + }; + + stub.mockReset = () => { + stub.mockClear(); + implementation = (() => undefined) as T; + onceImplementations = []; + return stub; + }; + + stub.mockRestore = () => { + stub.mockReset(); + state.restore(); + implementation = undefined; + return stub; + }; + + stub.getMockImplementation = () => implementation; + stub.mockImplementation = (fn: T) => { + implementation = fn; + state.willCall(mockCall); + return stub; + }; + + stub.mockImplementationOnce = (fn: T) => { + onceImplementations.push(fn); + return stub; + }; + + function withImplementation(fn: T, cb: () => void): MockInstance; + function withImplementation( + fn: T, + cb: () => Promise + ): Promise>; + function withImplementation( + fn: T, + cb: () => void | Promise + ): MockInstance | Promise> { + const originalImplementation = implementation; + + implementation = fn; + state.willCall(mockCall); + implementationChangedTemporarily = true; + + const reset = () => { + implementation = originalImplementation; + implementationChangedTemporarily = false; + }; + + const result = cb(); + + if (result instanceof Promise) { + return result.then(() => { + reset(); + return stub; + }); + } + + reset(); + + return stub; + } + + stub.withImplementation = withImplementation; + + stub.mockReturnThis = () => + stub.mockImplementation(function (this: TReturns) { + return this; + } as any); + + stub.mockReturnValue = (val: TReturns) => + stub.mockImplementation((() => val) as any); + stub.mockReturnValueOnce = (val: TReturns) => + stub.mockImplementationOnce((() => val) as any); + + stub.mockResolvedValue = (val: Awaited) => + stub.mockImplementation((() => Promise.resolve(val as TReturns)) as any); + + stub.mockResolvedValueOnce = (val: Awaited) => + stub.mockImplementationOnce((() => + Promise.resolve(val as TReturns)) as any); + + stub.mockRejectedValue = (val: unknown) => + stub.mockImplementation((() => Promise.reject(val)) as any); + + stub.mockRejectedValueOnce = (val: unknown) => + stub.mockImplementationOnce((() => Promise.reject(val)) as any); + + Object.defineProperty(stub, "mock", { + get: () => mockContext, + }); + + state.willCall(mockCall); + + mocks.add(stub); + + return stub as any; +} + +export function fn( + implementation?: T +): Mock { + const enhancedSpy = enhanceSpy( + tinyspy.internalSpyOn( + { + spy: implementation || (function () {} as T), + }, + "spy" + ) + ); + if (implementation) { + enhancedSpy.mockImplementation(implementation); + } + + return enhancedSpy as any; +} diff --git a/packages/test/src/util.ts b/packages/test/src/util.ts index 8ec5a867f..be6e19cb3 100644 --- a/packages/test/src/util.ts +++ b/packages/test/src/util.ts @@ -1,6 +1,7 @@ import { internalEvents } from "inngest"; import type { Context, EventPayload } from "inngest/types"; import { ulid } from "ulid"; +import { fn as mockFn } from "./spy.js"; /** * The default context transformation function that mocks all step tools. Use @@ -8,33 +9,23 @@ import { ulid } from "ulid"; * 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 = mockFn(tool); + const step = Object.keys(ctx.step).reduce( + (acc, key) => { + const tool = ctx.step[key as keyof typeof ctx.step]; + const mock = mockFn(tool); - // class FunctionWrapper extends Function { - // constructor(...args: any[]) { - // return mockFn(tool).apply(this, args); - // } - // } + return { + ...acc, + [key]: mock, + }; + }, + {} as Context.Any["step"] + ); - // const mock = FunctionWrapper; - // // const mock2 = jest.fn(tool); - - // return { - // ...acc, - // [key]: mock, - // }; - // }, - // {} as Context.Any["step"] - // ); - - // return { - // ...ctx, - // step, - // }; - return ctx; + return { + ...ctx, + step, + }; }; /** diff --git a/packages/test/tsconfig.json b/packages/test/tsconfig.json index b93f8983c..52ecc4d62 100644 --- a/packages/test/tsconfig.json +++ b/packages/test/tsconfig.json @@ -9,7 +9,10 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "strictNullChecks": true, + "moduleResolution": "node" }, "include": ["./src/**/*"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16bcc9f51..aeb6c201f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,9 @@ importers: inngest: specifier: ^3.22.9 version: 3.22.9(@sveltejs/kit@1.27.3)(@vercel/node@2.15.9)(aws-lambda@1.0.7)(express@4.19.2)(fastify@4.21.0)(h3@1.8.1)(hono@4.2.7)(koa@2.14.2)(next@13.5.4)(typescript@5.5.2) + tinyspy: + specifier: ^3.0.2 + version: 3.0.2 ulid: specifier: ^2.3.0 version: 2.3.0 @@ -4516,6 +4519,10 @@ packages: resolution: {integrity: sha512-iNgFugVuQgBKrqeO/mpiTTgmBsTP0WL6yeuLfLs/Ctf0pI/ixGqIRm8sDCwMcXGe9WWvt2sGXI5mNqZbValmJg==} engines: {node: '>=12'} + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + titleize@3.0.0: resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} engines: {node: '>=12'} @@ -9777,6 +9784,8 @@ snapshots: tiny-lru@11.0.1: {} + tinyspy@3.0.2: {} + titleize@3.0.0: {} tmp@0.0.33: From ca8616c551136fe8f77cf2784041631f15afcc0b Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Thu, 12 Sep 2024 19:19:11 +0000 Subject: [PATCH 11/17] Update `inngest` peer dep to `^3.22.12` --- packages/test/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/test/package.json b/packages/test/package.json index c132633cc..3beb8f254 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -43,7 +43,7 @@ "ulid": "^2.3.0" }, "peerDependencies": { - "inngest": "^3.22.9" + "inngest": "^3.22.12" }, "devDependencies": { "prettier": "^3.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aeb6c201f..0f6d48cf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,8 +303,8 @@ importers: packages/test: dependencies: inngest: - specifier: ^3.22.9 - version: 3.22.9(@sveltejs/kit@1.27.3)(@vercel/node@2.15.9)(aws-lambda@1.0.7)(express@4.19.2)(fastify@4.21.0)(h3@1.8.1)(hono@4.2.7)(koa@2.14.2)(next@13.5.4)(typescript@5.5.2) + specifier: ^3.22.12 + version: 3.22.12(@sveltejs/kit@1.27.3)(@vercel/node@2.15.9)(aws-lambda@1.0.7)(express@4.19.2)(fastify@4.21.0)(h3@1.8.1)(hono@4.2.7)(koa@2.14.2)(next@13.5.4)(typescript@5.5.2) tinyspy: specifier: ^3.0.2 version: 3.0.2 @@ -2997,8 +2997,8 @@ packages: typescript: optional: true - inngest@3.22.9: - resolution: {integrity: sha512-ZD/Q450EU7LYlkd24uixWlyhX1/lTkXrWiOhyDGAF5ahKaaVXECOTSW3gCSV/340T7UxNefcwZK5T4BAzxcADw==} + inngest@3.22.12: + resolution: {integrity: sha512-4OquNAvHvqoAKiZzC3bOJjOToOtDQACaLAAaNvpzMlHQxqYxuTCtrnrYulmSoWo7ueYXXHlE0cA/E9pHpv0vLg==} engines: {node: '>=14'} peerDependencies: '@sveltejs/kit': '>=1.27.3' @@ -8107,7 +8107,7 @@ snapshots: - encoding - supports-color - inngest@3.22.9(@sveltejs/kit@1.27.3)(@vercel/node@2.15.9)(aws-lambda@1.0.7)(express@4.19.2)(fastify@4.21.0)(h3@1.8.1)(hono@4.2.7)(koa@2.14.2)(next@13.5.4)(typescript@5.5.2): + inngest@3.22.12(@sveltejs/kit@1.27.3)(@vercel/node@2.15.9)(aws-lambda@1.0.7)(express@4.19.2)(fastify@4.21.0)(h3@1.8.1)(hono@4.2.7)(koa@2.14.2)(next@13.5.4)(typescript@5.5.2): dependencies: '@types/debug': 4.1.12 canonicalize: 1.0.8 From 376ba0919dbafca5e8b6968ed04f8b7f1c85e500 Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Thu, 12 Sep 2024 19:19:40 +0000 Subject: [PATCH 12/17] Allow `state` assertions using `.resolves`/`,rejects` instead of proxies --- packages/test/src/InngestTestEngine.ts | 43 +++++++------------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/packages/test/src/InngestTestEngine.ts b/packages/test/src/InngestTestEngine.ts index a13e9aedd..19367cb05 100644 --- a/packages/test/src/InngestTestEngine.ts +++ b/packages/test/src/InngestTestEngine.ts @@ -7,10 +7,10 @@ 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 type { Context, EventPayload } from "inngest/types"; import { ulid } from "ulid"; import { InngestTestRun } from "./InngestTestRun.js"; -import { Mock, fn as mockFn } from "./spy.js"; +import type { Mock } from "./spy.js"; import { createMockEvent, mockCtx, type DeepPartial } from "./util.js"; /** @@ -131,10 +131,7 @@ export namespace InngestTestEngine { * A mocked state object that allows you to assert step usage, input, and * output. */ - export type MockState = Record< - string, - Mock<(...args: unknown[]) => Promise> - >; + export type MockState = Record>; /** * The output of an individual function execution. @@ -328,41 +325,23 @@ export class InngestTestEngine { async (acc, stepId) => { const op = ops[stepId]; - if (op?.seen === false || !op?.rawArgs) { + if ( + op?.seen === false || + !op?.rawArgs || + !op?.fulfilled || + !op?.promise + ) { return acc; } - const mock = await mockFn(async (...args: unknown[]) => { - if ("error" in op) { - throw op.error; - } - - return op.data; - }); - - // execute it to show it was hit - mock(op.rawArgs); - return { ...(await acc), - [stepId]: mock, + [stepId]: op.promise, }; }, Promise.resolve({}) as Promise ); - // 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 mockFn(); - }, - }); - const run = new InngestTestRun({ testEngine: this.clone(options), }); @@ -370,7 +349,7 @@ export class InngestTestEngine { return { result, ctx: ctx as InngestTestEngine.MockContext, - state: mockStateProxy, + state: mockState, run, }; } From 170e94f7016b90ce60a2360baf77f64a0c4546bb Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Fri, 13 Sep 2024 18:07:35 +0000 Subject: [PATCH 13/17] Refactor to use `.execute()` and `.executeStep()` --- packages/test/src/InngestTestEngine.ts | 146 ++++++++++++++++++++++++- packages/test/src/InngestTestRun.ts | 37 +++++-- packages/test/src/util.ts | 11 +- 3 files changed, 178 insertions(+), 16 deletions(-) diff --git a/packages/test/src/InngestTestEngine.ts b/packages/test/src/InngestTestEngine.ts index 19367cb05..c5b642ca6 100644 --- a/packages/test/src/InngestTestEngine.ts +++ b/packages/test/src/InngestTestEngine.ts @@ -7,7 +7,7 @@ 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 type { Context, EventPayload } from "inngest/types"; +import { Context, EventPayload, StepOpCode } from "inngest/types"; import { ulid } from "ulid"; import { InngestTestRun } from "./InngestTestRun.js"; import type { Mock } from "./spy.js"; @@ -114,7 +114,7 @@ export namespace InngestTestEngine { * Options that can be passed to an initial execution that then waits for a * particular checkpoint to occur. */ - export type ExecuteAndWaitForOptions< + export type ExecuteOptions< T extends InngestTestRun.CheckpointKey = InngestTestRun.CheckpointKey, > = InlineOptions & { /** @@ -127,6 +127,10 @@ export namespace InngestTestEngine { subset?: DeepPartial>; }; + export type ExecuteStepOptions = InlineOptions & { + subset?: DeepPartial>; + }; + /** * A mocked state object that allows you to assert step usage, input, and * output. @@ -199,7 +203,137 @@ export class InngestTestEngine { * * Is a shortcut for and uses `run.waitFor()`. */ - public async executeAndWaitFor( + public async execute( + /** + * Options and state to start the run with. + */ + inlineOpts?: InngestTestEngine.ExecuteOptions + ): Promise { + const { run } = await this.individualExecution(inlineOpts); + + return run + .waitFor("function-resolved") + .then((output) => { + return { + ctx: output.ctx, + state: output.state, + result: output.result.data, + }; + }) + .catch((rejectedOutput) => { + if ( + typeof rejectedOutput === "object" && + rejectedOutput !== null && + "ctx" in rejectedOutput && + "state" in rejectedOutput + ) { + return { + ctx: rejectedOutput.ctx, + state: rejectedOutput.state, + error: rejectedOutput.error, + }; + } + + throw rejectedOutput; + }); + } + + /** + * Start a run from the given state and keep executing the function until the + * given step has run. + */ + public async executeStep( + /** + * The ID of the step to execute. + */ + stepId: string, + + /** + * Options and state to start the run with. + */ + inlineOpts?: InngestTestEngine.ExecuteOptions + ): Promise { + const { run, result: resultaaa } = await this.individualExecution({ + ...inlineOpts, + // always overwrite this so it's easier to capture non-runnable steps in + // the same flow. + disableImmediateExecution: true, + }); + + const foundSteps = await run.waitFor("steps-found", { + steps: [{ id: stepId }], + }); + + const hashedStepId = _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: InngestTestRun.RunStepOutput = { + ctx: foundSteps.ctx, + state: foundSteps.state, + step, + }; + + const opHandlers: Record< + StepOpCode, + () => InngestTestRun.RunStepOutput | void + > = { + // runnable, so do nothing now + [StepOpCode.StepPlanned]: () => {}, + + [StepOpCode.InvokeFunction]: () => baseRet, + [StepOpCode.Sleep]: () => baseRet, + [StepOpCode.StepError]: () => ({ ...baseRet, error: step.error }), + [StepOpCode.StepNotFound]: () => baseRet, + [StepOpCode.StepRun]: () => ({ ...baseRet, result: step.data }), + [StepOpCode.WaitForEvent]: () => baseRet, + [StepOpCode.Step]: () => ({ ...baseRet, result: step.data }), + }; + + const result = opHandlers[step.op](); + if (result) { + return result; + } + + // otherwise, run the step and return the output + const runOutput = await 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. + */ + protected async executeAndWaitFor( /** * The checkpoint to wait for. */ @@ -208,9 +342,9 @@ export class InngestTestEngine { /** * Options and state to start the run with. */ - inlineOpts?: InngestTestEngine.ExecuteAndWaitForOptions + inlineOpts?: InngestTestEngine.ExecuteOptions ): Promise> { - const { run } = await this.execute(inlineOpts); + const { run } = await this.individualExecution(inlineOpts); return run.waitFor(checkpoint, inlineOpts?.subset); } @@ -218,7 +352,7 @@ export class InngestTestEngine { /** * Execute the function with the given inline options. */ - public async execute( + protected async individualExecution( inlineOpts?: InngestTestEngine.InlineOptions ): Promise { const options = { diff --git a/packages/test/src/InngestTestRun.ts b/packages/test/src/InngestTestRun.ts index e32589a7d..f7314982f 100644 --- a/packages/test/src/InngestTestRun.ts +++ b/packages/test/src/InngestTestRun.ts @@ -1,3 +1,4 @@ +import { OutgoingOp } from "inngest"; import type { ExecutionResult, ExecutionResults, @@ -36,6 +37,16 @@ export namespace InngestTestRun { Extract, "ctx" | "ops" >; + + export interface RunOutput + extends Pick { + result?: Checkpoint<"function-resolved">["data"]; + error?: Checkpoint<"function-rejected">["error"]; + } + + export interface RunStepOutput extends RunOutput { + step: OutgoingOp; + } } /** @@ -72,7 +83,10 @@ export class InngestTestRun { subset?: DeepPartial> ): Promise> { let finished = false; - const runningState: InngestTestEngine.InlineOptions = {}; + const runningState: InngestTestEngine.InlineOptions = { + events: this.options.testEngine["options"].events, + steps: this.options.testEngine["options"].steps, + }; const { promise, resolve, reject } = createDeferredPromise>(); @@ -81,13 +95,7 @@ export class InngestTestRun { finished = true; if (output.result.type !== checkpoint) { - reject( - new Error( - `Expected checkpoint "${checkpoint}" but got "${ - output.result.type - }": ${JSON.stringify(output.result, null, 2)}` - ) - ); + return reject(output); } resolve(output as InngestTestEngine.ExecutionOutput); @@ -100,6 +108,8 @@ export class InngestTestRun { */ const sanitizedSubset: typeof subset = subset && { ...subset, + + // "step" for "step-ran" ...("step" in subset && typeof subset.step === "object" && subset.step !== null && @@ -107,6 +117,15 @@ export class InngestTestRun { typeof subset.step.id === "string" && { step: { ...subset.step, id: _internals.hashId(subset.step.id) }, }), + + // "steps" for "steps-found" + ...("steps" in subset && + Array.isArray(subset.steps) && { + steps: subset.steps.map((step) => ({ + ...step, + id: _internals.hashId(step.id), + })), + }), }; const processChain = async (targetStepId?: string) => { @@ -114,7 +133,7 @@ export class InngestTestRun { return; } - const exec = await this.options.testEngine.execute({ + const exec = await this.options.testEngine["individualExecution"]({ ...runningState, targetStepId, }); diff --git a/packages/test/src/util.ts b/packages/test/src/util.ts index be6e19cb3..973acb20c 100644 --- a/packages/test/src/util.ts +++ b/packages/test/src/util.ts @@ -60,15 +60,24 @@ export const isDeeplyEqual = ( const subsetValue = subset[key as keyof T]; const actualValue = actual[key as keyof T]; + // an array? find all of the values + if (Array.isArray(subsetValue) && Array.isArray(actualValue)) { + return subsetValue.every((subValue, i) => { + return isDeeplyEqual(subValue, actualValue[i]); + }); + } + + // a non-array object? if ( typeof subsetValue === "object" && subsetValue !== null && typeof actualValue === "object" && actualValue !== null ) { - return isDeeplyEqual(subsetValue, actualValue); + return isDeeplyEqual(subsetValue as T, actualValue); } + // anything else return subsetValue === actualValue; }); }; From 74077ef4d73e55b85b4b1209eb9ba7a507314411 Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Fri, 13 Sep 2024 18:07:46 +0000 Subject: [PATCH 14/17] Add README.md --- packages/test/README.md | 300 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 297 insertions(+), 3 deletions(-) diff --git a/packages/test/README.md b/packages/test/README.md index df3ae75cd..6dd543ee8 100644 --- a/packages/test/README.md +++ b/packages/test/README.md @@ -1,5 +1,299 @@ # @inngest/test -- [ ] TODO Make Inngest a peer dep once package changes are shipped -- [ ] TODO Re-add release scripts -- [ ] TODO Copy `CHANGELOG.md` +This package helps you test your Inngest functions with Jest-compatible mocking, +allowing you to mock function state, step tooling, and inputs. Jest +compatibility means we aim for compatibility with all major testing frameworks, +runtimes, and libraries: + +- `jest` +- `vitest` +- `bun:test` (Bun) +- `@std/expect` (Deno) +- `chai`/`expect` + +## Installation + +This package requires `inngest@>=3.22.12`. + +``` +npm install -D @inngest/test +``` + +## Running tests + +Use whichever supported testing framework; `@inngest/test` is unopinionated +about how your tests are run. We'll demonstrate here using `jest`. + +We import `InngestTestEngine` and our target function, `helloWorld`, and create +a new `InngestTestEngine` instance. + +```ts +import { InngestTestEngine } from "@inngest/test"; +import { helloWorld } from "./helloWorld"; + +describe("helloWorld function", () => { + const t = new InngestTestEngine({ + function: helloWorld, + }); +}); +``` + +Within that, we'll add a new test using the primary API, +`t.execute()`: + +```ts +test("returns a greeting", async () => { + const { result } = await t.execute(); + expect(result).toEqual("Hello World!"); +}); +``` + +This will run the entire function (steps and all) to completion, then return the +response from the function, where we assert that it was the string `"Hello +World!"`. + +An error + +### Running an individual step + +`t.executeStep()` can be used to run the function until a particular step has +been executed. This is useful to test a single step within a function or to see +that a non-runnable step such as `step.waitForEvent()` has been registered with +the correct options. + +```ts +test("runs the price calculations", async () => { + const { result } = await t.executeStep("calculate-price"); + expect(result).toEqual(123); +}); +``` + +Assertions can also be made on steps in any part of a run, regardless of if +that's the checkpoint we've waited for. See [Assertions -> State](#). + +## Assertions + +Inngest adds like Jest-compatible mocks by default that can help you assert +function and step input and output. You can assert: + +- Function input +- Function output +- Step output +- Step tool usage + +All of these values are returned from both `t.execute()` and `t.executeStep()`; +we'll only show one for simplicity here. + +The `result` is returned, which is the output of the run or step: + +```ts +const { result } = await t.execute(); +expect(result).toEqual("Hello World!"); +``` + +`ctx` is the input used for the function run. This can be used to assert outputs +that are based on input data such as `event` or `runId`: + +```ts +const { ctx, result } = await t.execute(); +expect(result).toEqual(`Run ID was: "${ctx.runId}"`); +``` + +> [!NOTE] +> The tests also run middleware, so you can test that middleware inputs are also +> being used correctly. + +The step tooling at `ctx.step` are Jest-compatible spy functions, so you can use +them to assert that they've been called and used correctly: + +```ts +const { ctx } = await t.execute(); +expect(ctx.step.run).toHaveBeenCalledWith("my-step", expect.any(Function)); +``` + +`state` is also returned, which is a view into the outputs of all of the steps +in the run. This allows you to test each individual step output for any given +input: + +```ts +const { state } = await t.execute(); +expect(state["my-step"]).resolves.toEqual("some successful output"); +expect(state["dangerous-step"]).rejects.toThrowError("something failed"); +``` + +## Mocking + +Some mocking is done automatically by `@inngest/test`, but can be overwritten if +needed. + +All mocks (detailed below) can be specified either when creating an `InngestTestEngine` instance +or for each individual execution: + +```ts +// Set the events for every execution +const t = new InngestTestEngine({ + function: helloWorld, + // mocks here +}); + +// Or for just one, which will overwrite any current event mocks +t.execute({ + // mocks here +}); + +t.executeStep("my-step", { + // mocks here +}) +``` + +You can also clone an existing `InngestTestEngine` instance to encourage re-use +of complex mocks: + +```ts +// Make a direct clone, which includes any mocks +const otherT = t.clone(); + +// Provide some more mocks in addition to any existing ones +const anotherT = t.clone({ + // mocks here +}); +``` + +For simplicity, the following examples will show usage of +`t.execute()`, but the mocks can be placed in any of these locations. + +### Event data + +The incoming event data can be mocked. They are always specified as an array of +events to allow also mocking batches. + +```ts +t.execute({ + events: [{ name: "demo/event.sent", data: { message: "Hi!" } }], +}); +``` + +If no event mocks are given at all (or `events: undefined` is explicitly set), +an `inngest/function.invoked` event will be mocked for you. + +### Step data + +Mocking step data can help you model different paths and situations within your +function. To do so, any step can be mocked by providing the `steps` option. + +Here we mock two steps, one that will run successfully and another that will +model a failure and throw an error. + +```ts +t.execute({ + steps: [ + { + id: "successful-step", + handler() { + return "We did it!"; + }, + }, + { + id: "dangerous-step", + handler() { + throw new Error("Oh no!"); + }, + }, + ], +}); +``` + +These handlers will run lazily when they are found during a function's +execution. This means you can write complex mocks that respond to other +information: + +```ts +let message = ""; + +t.execute({ + steps: [ + { + id: "build-greeting", + handler() { + message = "Hello, "; + return message; + }, + }, + { + id: "build-name", + handler() { + return message + " World!"; + }, + }, + ], +}); +``` + +> [!NOTE] +> We'll later add `ctx` and `state` to the input of `handler`, meaning you'll +> get much easier access to existing state and function input in order to +> provide more accurate mocks. + +### Modules and imports + +Any mocking of modules or imports outside of Inngest which your functions may +rely on should be done outside of Inngest with the testing framework you're +using. For convenience, here are some links to the major supported frameworks +and their guidance for mocking imports: + +- `jest` +- `vitest` +- `bun:test` (Bun) +- `@std/expect` (Deno) +- `chai`/`expect` + +### Custom + +While the package performs some basic mocks of the input object to a function in +order to spy on `ctx.step.*`, you can provide your own mocks for the function +input to do whatever you want with. + +When instantiating a new `InngestTestEngine` or starting an execution, provide a +`transformCtx` function that will add these mocks every time the function is +run: + +```ts +const t = new InngestTestEngine({ + function: helloWorld, + transformCtx: (ctx) => { + return { + ...ctx, + event: someCustomThing, + }; + }, +}); +``` + +If you wish to still add the automatic spies to `ctx.step.*`, you can import and +use the automatic transforms as part of your own: + +```ts +import { InngestTestEngine, mockCtx } from "@inngest/test"; + +const t = new InngestTestEngine({ + function: helloWorld, + transformCtx: (ctx) => { + return { + ...mockCtx(ctx), + event: someCustomThing, + }; + }, +}); +``` + +## Known issues / TODO + +- There are currently no retries modelled; any step or function that fails once + will fail permanently +- `onFailure` handlers are not run automatically +- Mocked step outputs do not model the JSON (de)serialization process yet, so + some typing may be off (e.g. `Date`) +- You cannot specify any `reqArgs` yet, which could affect some middleware usage + that relies on a particular serve handler being used +- Calling `inngest.send()` within a function is not yet automatically mocked, likely + resulting in an error From 0482dccca7ca79a61e0baa59aa94c98f24f660a6 Mon Sep 17 00:00:00 2001 From: Jack Williams <1736957+jpwilliams@users.noreply.github.com> Date: Fri, 13 Sep 2024 18:20:33 +0000 Subject: [PATCH 15/17] Add links to README --- packages/test/README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/test/README.md b/packages/test/README.md index 6dd543ee8..91c5d79c1 100644 --- a/packages/test/README.md +++ b/packages/test/README.md @@ -69,7 +69,7 @@ test("runs the price calculations", async () => { ``` Assertions can also be made on steps in any part of a run, regardless of if -that's the checkpoint we've waited for. See [Assertions -> State](#). +that's the checkpoint we've waited for. See [Assertions -> State](#assertions). ## Assertions @@ -241,11 +241,10 @@ rely on should be done outside of Inngest with the testing framework you're using. For convenience, here are some links to the major supported frameworks and their guidance for mocking imports: -- `jest` -- `vitest` -- `bun:test` (Bun) -- `@std/expect` (Deno) -- `chai`/`expect` +- [`jest`](https://jestjs.io/docs/mock-functions#mocking-modules) +- [`vitest`](https://vitest.dev/guide/mocking#modules) +- [`bun:test` (Bun)](https://bun.sh/docs/test/mocks#module-mocks-with-mock-module) +- [`@std/testing` (Deno)](https://jsr.io/@std/testing/doc/mock/~) ### Custom From 3bb13a6326dd33c69300c8afd9e58d9bbadcd47e Mon Sep 17 00:00:00 2001 From: Jack Williams Date: Mon, 16 Sep 2024 18:21:18 +0100 Subject: [PATCH 16/17] Update README.md --- packages/test/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/test/README.md b/packages/test/README.md index 91c5d79c1..5ef1185f4 100644 --- a/packages/test/README.md +++ b/packages/test/README.md @@ -11,6 +11,23 @@ runtimes, and libraries: - `@std/expect` (Deno) - `chai`/`expect` +## Table of contents + +- [Installation](#installation) +- [Running tests](#running-tests) + - [Running an entire function](#running-tests) + - [Running an individual step](#running-an-individual-step) +- [Assertions](#assertions) + - [Function/step output](#assertions) + - [Function input](#assertions) + - [Step state](#assertions) +- [Mocking](#mocking) + - [Events](#event-data) + - [Steps](#step-data) + - [Modules and imports](#modules-and-imports) + - [Custom](#custom) +- [TODO](#known-issues--todo) + ## Installation This package requires `inngest@>=3.22.12`. From 3b8e43cd885f54a402fcb1698d3595162fd04359 Mon Sep 17 00:00:00 2001 From: Jack Williams Date: Mon, 14 Oct 2024 13:17:26 +0100 Subject: [PATCH 17/17] Create violet-bikes-study.md --- .changeset/violet-bikes-study.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/violet-bikes-study.md diff --git a/.changeset/violet-bikes-study.md b/.changeset/violet-bikes-study.md new file mode 100644 index 000000000..7b93d215f --- /dev/null +++ b/.changeset/violet-bikes-study.md @@ -0,0 +1,5 @@ +--- +"@inngest/test": minor +--- + +Refactor `@inngest/test` to have a much simpler public API