Skip to content

Instantly share code, notes, and snippets.

@kamilkisiela
Created January 2, 2023 14:04
Show Gist options
  • Save kamilkisiela/751591acc126e1d72b736a262e8aef38 to your computer and use it in GitHub Desktop.
Save kamilkisiela/751591acc126e1d72b736a262e8aef38 to your computer and use it in GitHub Desktop.
Checks - step by step
import { createCheck, runCheck } from '../providers/checks';
// <step status> (<requirement>) -> <step status> (<requirement>) = <failed step index 1,2...n> (<result status>)
runTest('skipped (optional) -> skipped (optional) = (completed)');
runTest('completed (optional) -> skipped (optional) = (completed)');
runTest('skipped (optional) -> skipped (required) = 2 (failed)');
runTest('skipped (required) -> skipped (required) = 1 (failed)');
runTest('completed (required) -> skipped (required) = 2 (failed)');
runTest('completed (optional) -> skipped (required) = 2 (failed)');
runTest('completed (optional) -> completed (required) -> failed (required) = 3 (failed)');
runTest('completed (optional) -> completed (required) -> failed (optional) = 3 (failed)');
//
function parseResult(scenario: string) {
const parts = scenario.trim().split('(');
if (parts.length === 2) {
return {
index: parseInt(parts[0], 10) - 1,
status: parts[1].replace(')', ''),
};
}
return {
index: null,
status: parts[0].replace(')', ''),
};
}
function parseSteps(scenario: string) {
return scenario.split(' -> ').map((check, i) => {
const [status, requirement] = Array.from(check.match(/(\w+)\s+\((\w+)\)/)!).slice(1);
const stepId: string = `step-${i}` as const;
if (requirement !== 'required' && requirement !== 'optional') {
throw new Error(`Invalid requirement: ${requirement}`);
}
return {
stepId,
status: status as 'skipped' | 'completed' | 'failed',
requirement: requirement as 'required' | 'optional',
};
});
}
function runTest(scenario: string) {
const [stepsScenario, resultScenario] = scenario.split(' = ');
const steps = parseSteps(stepsScenario).map(step => {
return {
...step,
check: createCheck(step.stepId, async () => {
if (step.status === 'skipped') {
return {
status: 'skipped',
};
}
if (step.status === 'completed') {
return {
status: 'completed',
result: null,
};
}
if (step.status === 'failed') {
return {
status: 'failed',
reason: null,
};
}
throw new Error(`Invalid status: ${step.status}`);
}),
};
});
const { index, status } = parseResult(resultScenario);
test(scenario, async () => {
const result = await runCheck(
steps.map(s => s.check),
steps.reduce((acc, s) => ({ ...acc, [s.check.id]: s.requirement }), {}),
);
const expectedState: any = {};
for (const step of steps) {
expectedState[step.stepId] = {
id: step.stepId,
status: 'skipped',
};
}
for await (const [i, step] of steps.entries()) {
if (index != null && i > index) {
break;
}
const r = await step.check.runner();
expectedState[step.stepId] = {
...r,
id: step.stepId,
};
}
if (index !== null) {
expectedState;
}
expect(result).toEqual({
status,
state: expectedState,
...(index == null
? {}
: {
step: expectedState[`step-${index}`],
}),
});
});
}
import type * as tst from 'ts-toolbelt';
export async function runCheck<T extends Check<string, unknown, unknown>[]>(
checks: T,
rules: {
[K in keyof CheckListToObject<T>]: 'required' | 'optional';
},
): Promise<
| { status: 'completed'; state: NonFailedState<CheckListToObject<T>> }
| {
status: 'failed';
state: CheckListToObject<T>;
step: NonCompleted<CheckResultOf<T[number]>> & {
id: keyof CheckListToObject<T>;
};
}
> {
const state: CheckListToObject<T> = checks.reduce(
(acc, { id }) => ({
...acc,
[id]: {
id,
status: 'skipped',
},
}),
{} as any,
);
let status: 'completed' | 'failed' = 'completed';
let failedStep: any | null = null;
for await (const step of checks) {
const r = await step.runner();
const id = step.id as unknown as keyof typeof state;
state[id] = {
...(r as any),
id,
};
const isRequired = rules[id] === 'required';
if ((isRequired && !isCompleted(r)) || r.status === 'failed') {
failedStep = {
...r,
id,
};
status = 'failed';
break;
}
}
if (status === 'failed') {
return {
status,
step: failedStep,
state,
};
}
return {
status,
state: state as NonFailedState<CheckListToObject<T>>,
};
}
export function createCheck<K extends string, C, F>(
id: K,
runner: () => Promise<CheckResult<C, F>>,
) {
return {
id,
runner,
};
}
// The reason why I'm using `result` and `reason` instead of just `data` for both:
// https://bit.ly/hive-check-result-data
export type CheckResult<C = unknown, F = unknown> =
| {
status: 'completed';
result: C;
}
| {
status: 'failed';
reason: F;
}
| {
status: 'skipped';
};
type CheckResultOf<T> = T extends Check<string, infer C, infer F> ? CheckResult<C, F> : never;
type Check<K extends string, C, F> = {
id: K;
runner: () => Promise<CheckResult<C, F>>;
};
type CheckListToObject<T extends ReadonlyArray<Check<string, unknown, unknown>>> = tst.Union.Merge<
T extends ReadonlyArray<infer U>
? U extends Check<infer IK, unknown, unknown>
? {
[P in IK]: U['id'] extends P
? CheckResultOf<U> & {
id: P;
}
: never;
}
: never
: never
>;
function isCompleted<T extends CheckResult<unknown, unknown>>(step: T): step is Completed<T> {
return step.status === 'completed';
}
type Completed<T> = T extends CheckResult<unknown, unknown>
? T extends { status: 'completed' }
? T
: never
: never;
type NonCompleted<T> = T extends CheckResult<unknown, unknown>
? T extends { status: 'completed' }
? never
: T
: never;
type NonFailed<T> = T extends CheckResult<unknown, unknown>
? T extends { status: 'failed' }
? never
: T
: never;
type WithId<T, K> = T & {
id: K;
};
type NonFailedState<T> = T extends {
[K in keyof T]: WithId<CheckResult<unknown, unknown>, K>;
}
? {
[K in keyof T]: NonFailed<T[K]>;
}
: never;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment