Skip to content

Commit

Permalink
feat: invert control flow (#62)
Browse files Browse the repository at this point in the history
This changes the implementation of the runner to allow for
better control of long-running steps.

Control is inverted and given to the step runners so they
can implement the best strategy for retrying.

Support for retry, tags, and configuration through tags
is removed. Step runners need to take care to ensure that
described asserting is met, without retry, because retrying
scenarios creates mostly too much noise and makes
it hard to determine the root cause. It also makes
flaky "normal", which should not be the case.

BREAKING CHANGE: this changes the API

See bifravst/bdd-markdown-e2e-example-aws#243 for a migration example
  • Loading branch information
coderbyheart authored Sep 11, 2023
1 parent bc75632 commit 43bbec5
Show file tree
Hide file tree
Showing 57 changed files with 1,549 additions and 3,333 deletions.
36 changes: 3 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,39 +87,9 @@ or times out. This way a back-off algorithm can be used to wait increasing
longer times and many tries during the test run will have the least amount of
impact on the run time.

The `Soon` keyword can be used to retry a step until a timeout is reached. It
can be configured through the `@retry` tag in a comment preceding the step, the
scenario. Pass one or multiple settings to override the default behavior.
Example: `@retry:tries=3,initialDelay=50,delayFactor=2`.

```markdown
---
# Configure the retry settings for the entire feature
retry:
tries: 10
initialDelay: 250
delayFactor: 2
---

# To Do List

<!-- This @retry:tries=5 applies to all steps in the scenario. -->

## Create a new todo list item

Given I create a new task named `My item`

<!-- This @retry:tries=3,initialDelay=100,delayFactor=1.5 applies only to the next step. -->

Soon the list of tasks should contain `My item`
```

The optional configuration `delayExecution` can be used to delay the execution
of a step retry. Example: `@retry:tries=5,delayExecution=1000`.

Using `@retryScenario` in a step comment, the entire scenario will be retried in
case a step fails, using the retry configuration of the step
([Example](./runner/test-data/runFeature/RetryScenario.feature.md)).
Implementing the appropriate way of retrying is left to the implementing step,
however you are encourage to mark these eventual consisted steps using the
`Soon` keyword.

## Control feature execution order via dependencies

Expand Down
7 changes: 7 additions & 0 deletions examples/firmware/RunFirmware.feature.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
---
exampleContext:
appVersion: 0.0.0-development
deviceId: my-device
idScope: 0n283efa
---

# Run the firmware

> The Asset Tracker should run the firmware
Expand Down
42 changes: 18 additions & 24 deletions examples/firmware/steps.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import assert from 'assert/strict'
import os from 'os'
import {
noMatch,
type StepRunner,
type StepRunnerArgs,
type StepRunResult,
} from '../../runner/runStep.js'
import type { StepRunner } from '../../runner/runSuite'

export type FirmwareCIRunContext = {
appVersion: string
Expand All @@ -15,25 +10,24 @@ export type FirmwareCIRunContext = {
}

export const steps: StepRunner<FirmwareCIRunContext>[] = [
async ({
step,
context: { deviceLog },
}: StepRunnerArgs<FirmwareCIRunContext>): Promise<StepRunResult> => {
if (!/^the Firmware CI run device log should contain$/.test(step.title))
return noMatch
const shouldContain = step.codeBlock?.code.split(os.EOL) ?? []
if (shouldContain.length === 0)
throw new Error(`Must provide content to match against!`)
<StepRunner<FirmwareCIRunContext>>{
match: (title) =>
/^the Firmware CI run device log should contain$/.test(title),
run: async ({ step, context: { deviceLog } }) => {
const shouldContain = step.codeBlock?.code.split(os.EOL) ?? []
if (shouldContain.length === 0)
throw new Error(`Must provide content to match against!`)

for (const line of shouldContain) {
try {
assert.equal(
deviceLog.find((s) => s.includes(line)) !== undefined,
true,
)
} catch {
throw new Error(`Device log does not contain "${line}"!`)
for (const line of shouldContain) {
try {
assert.equal(
deviceLog.find((s) => s.includes(line)) !== undefined,
true,
)
} catch {
throw new Error(`Device log does not contain "${line}"!`)
}
}
}
},
},
]
2 changes: 1 addition & 1 deletion examples/mars-rover/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Mars Rover kata

This example is included because it demonstrates the use of Scenario outlines
and testing asynchronous code, using the `Soon` keyword, which has built-in
and testing asynchronous code, using the `Soon` keyword, which demonstrates
retry logic.

The Rover in this implementation receives commands but does not reach the target
Expand Down
258 changes: 112 additions & 146 deletions examples/mars-rover/steps.ts
Original file line number Diff line number Diff line change
@@ -1,166 +1,132 @@
import { Type } from '@sinclair/typebox'
import assert from 'assert/strict'
import { matchGroups } from '../../runner/matchGroups.js'
import {
noMatch,
type StepRunner,
type StepRunnerArgs,
type StepRunResult,
} from '../../runner/runStep.js'
import { groupMatcher } from '../../runner/matchGroups.js'
import { Direction, rover } from './rover.js'

const printRover = (r: ReturnType<typeof rover>): string =>
`${r.x()},${r.y()} ${r.direction()}`
import type { StepRunner } from '../../runner/runSuite.js'
import { backOff } from 'exponential-backoff'

export type RoverContext = {
rover?: ReturnType<typeof rover>
obstacles?: [x: number, y: number][]
}

const coordinateMatch = matchGroups(
Type.Object({
x: Type.Integer(),
y: Type.Integer(),
}),
{
x: (s) => parseInt(s, 10),
y: (s) => parseInt(s, 10),
},
)
const Coordinates = Type.Object({
x: Type.Integer(),
y: Type.Integer(),
})
const coordinateTransformer = {
x: (s: string) => parseInt(s, 10),
y: (s: string) => parseInt(s, 10),
}

enum MovementDirection {
forward = 'forward',
backward = 'backward',
}

export const steps: StepRunner<RoverContext>[] = [
async ({
step,
log: {
scenario: { progress },
step: { progress: stepProgress },
<StepRunner<RoverContext>>{
match: (title) => /^I have a Mars Rover$/.test(title),
run: async ({ log: { progress }, context }) => {
progress('Creating a new rover')
const r = rover({
canMoveTo: ([x, y]) =>
(context.obstacles ?? []).find(([oX, oY]) => oX === x && oY === y) ===
undefined,
debug: (...args) => progress('Rover', ...args),
})
progress('Rover created')
context.rover = r
},
context,
}: StepRunnerArgs<RoverContext>): Promise<StepRunResult> => {
if (!/^I have a Mars Rover$/.test(step.title)) return noMatch
stepProgress('Creating a new rover')
const r = rover({
canMoveTo: ([x, y]) =>
(context.obstacles ?? []).find(([oX, oY]) => oX === x && oY === y) ===
undefined,
debug: (...args) => progress('Rover', ...args),
})
stepProgress('Rover created')
context.rover = r
return {
result: context.rover,
printable: printRover(r),
}
},
async ({
step,
context,
}: StepRunnerArgs<RoverContext>): Promise<StepRunResult> => {
const match = coordinateMatch(
/^I set the initial starting point to `(?<x>-?[0-9]+),(?<y>-?[0-9]+)`$/,
step.title,
)
if (match === null) return noMatch
const r = context.rover as ReturnType<typeof rover>
r.setX(match.x)
r.setY(match.y)
return {
result: r,
printable: printRover(r),
}
},
async ({
step,
context,
}: StepRunnerArgs<RoverContext>): Promise<StepRunResult> => {
const match = matchGroups(Type.Object({ direction: Type.Enum(Direction) }))(
/^I set the initial direction to `(?<direction>[^`]+)`$/,
step.title,
)

if (match === null) return noMatch
const r = context.rover as ReturnType<typeof rover>
r.setDirection(match.direction)
return {
result: r,
printable: printRover(r),
}
},
async ({
step,
context,
}: StepRunnerArgs<RoverContext>): Promise<StepRunResult> => {
enum MovementDirection {
forward = 'forward',
backward = 'backward',
}
const match = matchGroups(
Type.Object({
groupMatcher(
{
regExp:
/^I set the initial starting point to `(?<x>-?[0-9]+),(?<y>-?[0-9]+)`$/,
schema: Coordinates,
converters: coordinateTransformer,
},
async ({ match, context, log: { progress } }) => {
const r = context.rover as ReturnType<typeof rover>
r.setX(match.x)
r.setY(match.y)
progress(`Rover moved to ${match.x} ${match.y}`)
},
),
groupMatcher(
{
regExp: /^I set the initial direction to `(?<direction>[^`]+)`$/,
schema: Type.Object({ direction: Type.Enum(Direction) }),
},
async ({ match, context, log: { progress } }) => {
const r = context.rover as ReturnType<typeof rover>
r.setDirection(match.direction)
progress(`Rover direction set to ${match.direction}`)
},
),
groupMatcher(
{
regExp:
/^I move the Mars Rover `(?<direction>forward|backward)` (?<squares>[0-9]+) squares?$/,
schema: Type.Object({
direction: Type.Enum(MovementDirection),
squares: Type.Integer({ minimum: 1 }),
}),
{
converters: {
squares: (s) => parseInt(s, 10),
},
)(
/^I move the Mars Rover `(?<direction>forward|backward)` (?<squares>[0-9]+) squares?$/,
step.title,
)

if (match === null) return noMatch
const r = context.rover as ReturnType<typeof rover>
if (match.direction === MovementDirection.forward) r.forward(match.squares)
if (match.direction === MovementDirection.backward)
r.backward(match.squares)

return { result: r, printable: printRover(r) }
},
async ({
step,
context,
}: StepRunnerArgs<RoverContext>): Promise<StepRunResult> => {
const match = coordinateMatch(
/^the current position should be `(?<x>-?[0-9]+),(?<y>-?[0-9]+)`$/,
step.title,
)
if (match === null) return noMatch

const r = context.rover as ReturnType<typeof rover>

assert.deepEqual(r.x(), match.x)
assert.deepEqual(r.y(), match.y)
},
async ({
step,
context,
}: StepRunnerArgs<RoverContext>): Promise<StepRunResult> => {
const match = coordinateMatch(
/^there is an obstacle at `(?<x>-?[0-9]+),(?<y>-?[0-9]+)`$/,
step.title,
)

if (match === null) return noMatch

if (context.obstacles === undefined) context.obstacles = []

context.obstacles.push([match.x, match.y])
},
async ({
step,
context,
log: {
step: { debug },
},
}: StepRunnerArgs<RoverContext>): Promise<StepRunResult> => {
const match = coordinateMatch(
/^the Mars Rover should report an obstacle at `(?<x>-?[0-9]+),(?<y>-?[0-9]+)`$/,
step.title,
)
if (match === null) return noMatch
const r = context.rover as ReturnType<typeof rover>

debug('knownObstacles', JSON.stringify(r.knownObstacles()))

assert.deepEqual(r.knownObstacles(), [[match.x, match.y]])
},
async ({ match, context, log: { progress } }) => {
const r = context.rover as ReturnType<typeof rover>
progress(`Move rover ${match.direction}`)
if (match.direction === MovementDirection.forward)
r.forward(match.squares)
if (match.direction === MovementDirection.backward)
r.backward(match.squares)
},
),
groupMatcher(
{
regExp:
/^the current position should be `(?<x>-?[0-9]+),(?<y>-?[0-9]+)`$/,
schema: Coordinates,
converters: coordinateTransformer,
},
async ({ match, context, log: { progress } }) => {
// This implements the retry in the step using a linear backoff
await backOff(
async () => {
const r = context.rover as ReturnType<typeof rover>
progress(`Current position is ${r.x()} ${r.y()}`)
assert.deepEqual(r.x(), match.x)
assert.deepEqual(r.y(), match.y)
},
{ timeMultiple: 1 },
)
},
),
groupMatcher(
{
regExp: /^there is an obstacle at `(?<x>-?[0-9]+),(?<y>-?[0-9]+)`$/,
schema: Coordinates,
converters: coordinateTransformer,
},
async ({ match, context }) => {
if (context.obstacles === undefined) context.obstacles = []
context.obstacles.push([match.x, match.y])
},
),
groupMatcher(
{
regExp:
/^the Mars Rover should report an obstacle at `(?<x>-?[0-9]+),(?<y>-?[0-9]+)`$/,
schema: Coordinates,
converters: coordinateTransformer,
},
async ({ match, context, log: { debug } }) => {
const r = context.rover as ReturnType<typeof rover>
debug('knownObstacles', JSON.stringify(r.knownObstacles()))
assert.deepEqual(r.knownObstacles(), [[match.x, match.y]])
},
),
]
Loading

0 comments on commit 43bbec5

Please sign in to comment.