Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial @inngest/test package #704

Merged
merged 21 commits into from
Oct 21, 2024
Merged
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ebef13f
Reestablish build and release for `@inngest/test`
jpwilliams Sep 7, 2024
4e2625c
Add `DeepPartial` type for shoehorning subset matching
jpwilliams Sep 7, 2024
b25de92
Allow minimal matching when using `run.waitFor()`
jpwilliams Sep 7, 2024
4532ddb
Sanitize provided subsets for matching to abstract ID hashing
jpwilliams Sep 7, 2024
a0004d1
Fix incorrect checkpoints being returned during testing
jpwilliams Sep 7, 2024
8040933
Refactor `t.executeAndWaitFor()` to allow more friendly API
jpwilliams Sep 7, 2024
b2d3c9f
Remove `@jest/globals`; remove immediate mocking
jpwilliams Sep 12, 2024
a063768
Merge branch 'main' into feat/inngest-test
jpwilliams Sep 12, 2024
409b524
Make sure we have `prettier`
jpwilliams Sep 12, 2024
40d15f9
Log the result when encountering an unexpected checkpoint
jpwilliams Sep 12, 2024
b5c5580
Try to re-add automatic mocks for `ctx.step`
jpwilliams Sep 12, 2024
ca8616c
Update `inngest` peer dep to `^3.22.12`
jpwilliams Sep 12, 2024
376ba09
Allow `state` assertions using `.resolves`/`,rejects` instead of proxies
jpwilliams Sep 12, 2024
1a2c705
Merge branch 'main' into feat/inngest-test
jpwilliams Sep 12, 2024
170e94f
Refactor to use `.execute()` and `.executeStep()`
jpwilliams Sep 13, 2024
74077ef
Add README.md
jpwilliams Sep 13, 2024
0482dcc
Add links to README
jpwilliams Sep 13, 2024
3bb13a6
Update README.md
jpwilliams Sep 16, 2024
b56dd0d
Merge branch 'main' into feat/inngest-test
jpwilliams Oct 14, 2024
3b8e43c
Create violet-bikes-study.md
jpwilliams Oct 14, 2024
1452f4d
Merge branch 'main' into feat/inngest-test
jpwilliams Oct 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Refactor to use .execute() and .executeStep()
  • Loading branch information
jpwilliams committed Sep 13, 2024
commit 170e94f7016b90ce60a2360baf77f64a0c4546bb
146 changes: 140 additions & 6 deletions packages/test/src/InngestTestEngine.ts
Original file line number Diff line number Diff line change
@@ -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<InngestTestRun.Checkpoint<T>>;
};

export type ExecuteStepOptions = InlineOptions & {
subset?: DeepPartial<InngestTestRun.Checkpoint<"steps-found">>;
};

/**
* 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<T extends InngestTestRun.CheckpointKey>(
public async execute<T extends InngestTestRun.CheckpointKey>(
/**
* Options and state to start the run with.
*/
inlineOpts?: InngestTestEngine.ExecuteOptions<T>
): Promise<InngestTestRun.RunOutput> {
const { run } = await this.individualExecution(inlineOpts);

return run
.waitFor("function-resolved")
.then<InngestTestRun.RunOutput>((output) => {
return {
ctx: output.ctx,
state: output.state,
result: output.result.data,
};
})
.catch<InngestTestRun.RunOutput>((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<InngestTestRun.RunStepOutput> {
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<T extends InngestTestRun.CheckpointKey>(
/**
* The checkpoint to wait for.
*/
@@ -208,17 +342,17 @@ export class InngestTestEngine {
/**
* Options and state to start the run with.
*/
inlineOpts?: InngestTestEngine.ExecuteAndWaitForOptions<T>
inlineOpts?: InngestTestEngine.ExecuteOptions<T>
): Promise<InngestTestEngine.ExecutionOutput<T>> {
const { run } = await this.execute(inlineOpts);
const { run } = await this.individualExecution(inlineOpts);

return run.waitFor(checkpoint, inlineOpts?.subset);
}

/**
* Execute the function with the given inline options.
*/
public async execute(
protected async individualExecution(
inlineOpts?: InngestTestEngine.InlineOptions
): Promise<InngestTestEngine.ExecutionOutput> {
const options = {
37 changes: 28 additions & 9 deletions packages/test/src/InngestTestRun.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { OutgoingOp } from "inngest";
import type {
ExecutionResult,
ExecutionResults,
@@ -36,6 +37,16 @@ export namespace InngestTestRun {
Extract<ExecutionResult, { type: T }>,
"ctx" | "ops"
>;

export interface RunOutput
extends Pick<InngestTestEngine.ExecutionOutput, "ctx" | "state"> {
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<InngestTestRun.Checkpoint<T>>
): Promise<InngestTestEngine.ExecutionOutput<T>> {
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<InngestTestEngine.ExecutionOutput<T>>();
@@ -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<T>);
@@ -100,21 +108,32 @@ export class InngestTestRun {
*/
const sanitizedSubset: typeof subset = subset && {
...subset,

// "step" for "step-ran"
...("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) },
}),

// "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) => {
if (finished) {
return;
}

const exec = await this.options.testEngine.execute({
const exec = await this.options.testEngine["individualExecution"]({
...runningState,
targetStepId,
});
11 changes: 10 additions & 1 deletion packages/test/src/util.ts
Original file line number Diff line number Diff line change
@@ -60,15 +60,24 @@ export const isDeeplyEqual = <T extends object>(
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;
});
};