Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
147 changes: 134 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,97 @@ for await (const value of timeline) {
}
```

## Consistent timing

Timelines used to wait on real `setTimeout` timers, which made timing depend on wall-clock time — slow, and prone to drift between two timelines that are meant to line up. Instead, timing is now driven by a virtual `Clock` measured in _frames_ (one frame per dash). The clock only advances as a timeline is consumed, so timing is fully deterministic.

To coordinate two (or more) timelines — for example a source stream and the expectation it should produce — pass them the **same** `Clock` instance so they advance in lockstep:

```javascript
import { Clock, Timeline } from '@johngw/timeline'

const clock = new Clock()
const source = Timeline.create('--1--2------', { clock })
const expected = Timeline.create('-----T10-2--', { clock })
```

If you don't pass a clock, each `Timeline` gets its own fresh one.

### Driving real timers (e.g. testing a transformer)

The `Clock` above is enough when _everything_ runs on timeline time. But real code under test often uses real timers — `setTimeout`, `setInterval`, `Date`. Take a transformer that samples its latest value on an interval:

```typescript
export function sampleTime<T>(ms: number): TransformStream<T, T> {
let buffer: T
let hasSample = false
let interval: ReturnType<typeof setInterval>

return new TransformStream({
start(controller) {
interval = setInterval(() => {
if (hasSample) controller.enqueue(buffer)
}, ms)
},
transform(chunk) {
hasSample = true
buffer = chunk
},
flush: () => clearInterval(interval),
cancel: () => clearInterval(interval),
})
}
```

If you feed a timeline into this, the timeline advances on _frames_ while `setInterval` fires on _wall-clock time_ — they drift, and your test is flaky again. The fix is to put **both** on the same clock.

A `Timeline` accepts any [`Clockable`](src/Clock.ts), so you don't have to use the built-in `Clock`. Replace the global timers with a fake-timers library such as [`@sinonjs/fake-timers`](https://github.com/sinonjs/fake-timers), then hand the timeline a `Clockable` that reads and advances _that same fake clock_:

```typescript
import FakeTimers from '@sinonjs/fake-timers'
import { Timeline, type Clockable } from '@johngw/timeline'

// 1. Make the transformer's setInterval/setTimeout/Date controllable.
const fake = FakeTimers.install({
toFake: [
'setInterval',
'clearInterval',
'setTimeout',
'clearTimeout',
'Date',
],
})

try {
// 2. A Clockable backed by the *same* fake clock the transformer is on.
// One frame == one fake millisecond.
const clock: Clockable = {
get now() {
return fake.now
},
wait: (frames) =>
new Promise((resolve) => {
fake.setTimeout(resolve, frames)
}),
// `tickAsync` advances fake time *and* drains the promises the stream
// pipeline schedules, so an interval's `enqueue` surfaces before the
// next frame.
advance: (frames = 1) => fake.tickAsync(frames).then(() => {}),
}

// 3. Consuming a dash now ticks the same clock sampleTime's interval is on,
// so `T20` and `setInterval(20)` fire together — deterministically.
const source = Timeline.create('1-T40--------2--T20--|', { clock })
const expected = Timeline.create('T20-1-T20-1-T20-2---', { clock })

// …drive `source` into `sampleTime(20)` and assert against `expected`.
} finally {
fake.uninstall()
}
```

The whole bridge is the `clock` adapter: because the timeline advances that clock as it's consumed, the transformer's real timers advance in lockstep with timeline frames — no manual ticking, and no `setTimeout` left to make timing inconsistent. Note that `install()` is process-global, so install/uninstall it per test (the `try`/`finally` above).

## Examples

See real-world examples in the `@johngw/stream-test` package:
Expand Down Expand Up @@ -78,7 +169,7 @@ buffer(

### Timers

To signal waiting for a period of milliseconds, use a capital `T` followed by a number, representing the amount of `milliseconds` to wait for.
To signal waiting for a period of time, use a capital `T` followed by a number, representing the amount of time to wait for.

For example:

Expand All @@ -90,6 +181,8 @@ debounce(10)
-----T10-2--
```

Timing is **virtual**, not wall-clock. A timeline is driven by a [`Clock`](#consistent-timing) that advances one _frame_ per dash; a `Tn` finishes once the clock has advanced `n` frames. This makes timing deterministic and independent of how fast the machine is or how busy the event loop gets — no `setTimeout` is involved.

### Null

Although the keyword `null` can be used, a shorter `N` can also be used.
Expand All @@ -116,14 +209,21 @@ Each timeline item must have a parser. It should take from **the beginning** of

```typescript
import { outerface } from '@johngw/outerface'
import { TimelineItem, TimelineParsable } from '@johngw/timeline/TimelineItem'
import {
TimelineItem,
TimelineItemOptions,
TimelineParsable,
} from '@johngw/timeline/TimelineItem'

@outerface<TimelineParsable<FooBarTimelineItem>>()
export class FooBarTimelineItem extends TimelineItem<string> {
static parse(timeline: string) {
static parse(timeline: string, options: TimelineItemOptions) {
const result = this.createItemRegExp('(FOO)').exec(timeline)
return result
? [new FooBarTimelineItem(result[1]), timeline.slice(result[1].length)]
? [
new FooBarTimelineItem(result[1], options),
timeline.slice(result[1].length),
]
: undefined
}
}
Expand All @@ -135,14 +235,21 @@ Now we need to implement the rest of the `TimelineItem`.

```typescript
import { outerface } from '@johngw/outerface'
import { TimelineItem, TimelineParsable } from '@johngw/timeline/TimelineItem'
import {
TimelineItem,
TimelineItemOptions,
TimelineParsable,
} from '@johngw/timeline/TimelineItem'

@outerface<TimelineParsable<FooBarTimelineItem>>()
export class FooBarTimelineItem extends TimelineItem<string> {
static parse(timeline: string) {
static parse(timeline: string, options: TimelineItemOptions) {
const result = this.createItemRegExp('(FOO)').exec(timeline)
return result
? [new FooBarTimelineItem(result[1]), timeline.slice(result[1].length)]
? [
new FooBarTimelineItem(result[1], options),
timeline.slice(result[1].length),
]
: undefined
}

Expand Down Expand Up @@ -177,14 +284,21 @@ This method is called when a timeline item is reached.

```typescript
import { outerface } from '@johngw/outerface'
import { TimelineItem, TimelineParsable } from '@johngw/timeline/TimelineItem'
import {
TimelineItem,
TimelineItemOptions,
TimelineParsable,
} from '@johngw/timeline/TimelineItem'

@outerface<TimelineParsable<FooBarTimelineItem>>()
export class FooBarTimelineItem extends TimelineItem<string> {
static parse(timeline: string) {
static parse(timeline: string, options: TimelineItemOptions) {
const result = this.createItemRegExp('(FOO)').exec(timeline)
return result
? [new FooBarTimelineItem(result[1]), timeline.slice(result[1].length)]
? [
new FooBarTimelineItem(result[1], options),
timeline.slice(result[1].length),
]
: undefined
}

Expand All @@ -205,14 +319,21 @@ A method that is called just before reaching the next item.

```typescript
import { outerface } from '@johngw/outerface'
import { TimelineItem, TimelineParsable } from '@johngw/timeline/TimelineItem'
import {
TimelineItem,
TimelineItemOptions,
TimelineParsable,
} from '@johngw/timeline/TimelineItem'

@outerface<TimelineParsable<FooBarTimelineItem>>()
export class FooBarTimelineItem extends TimelineItem<string> {
static parse(timeline: string) {
static parse(timeline: string, options: TimelineItemOptions) {
const result = this.createItemRegExp('(FOO)').exec(timeline)
return result
? [new FooBarTimelineItem(result[1]), timeline.slice(result[1].length)]
? [
new FooBarTimelineItem(result[1], options),
timeline.slice(result[1].length),
]
: undefined
}

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"sideEffects": false,
"exports": {
".": "./dist/index.js",
"./Clock": "./dist/Clock.js",
"./Timeline": "./dist/Timeline.js",
"./TimelineItem": "./dist/TimelineItem/TimelineItem.js",
"./TimelineItemBoolean": "./dist/TimelineItem/TimelineItemBoolean.js",
Expand Down
86 changes: 86 additions & 0 deletions src/Clock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* The contract a timeline uses to tell the time.
*
* @remarks
* A timeline only needs three things from a clock: read the current time,
* wait for time to pass, and advance time. The built-in {@link Clock}
* implements this with a deterministic frame counter, but you can supply
* any implementation — for example one backed by a fake-timers library so
* the timeline shares a clock with the real `setInterval`/`setTimeout`
* timers inside the code under test. See the README's "Driving real
* timers" section.
*/
export interface Clockable {
/**
* The current virtual time, measured in frames.
*/
readonly now: number

/**
* Returns a promise that resolves once the clock has advanced by
* `frames` frames.
*/
wait(frames: number): Promise<void>

/**
* Advances virtual time by `frames` (default 1). May be asynchronous so
* that implementations backed by real/fake timers can flush pending
* timer callbacks and microtasks while advancing.
*/
advance(frames?: number): void | Promise<void>
}

/**
* The default {@link Clockable}: a virtual clock that drives timeline
* timing deterministically.
*
* @remarks
* Instead of relying on wall-clock timers (`setTimeout`), time is modelled
* as an integer number of "frames". The clock only ever advances when a
* timeline is consumed (one frame per dash), so two timelines sharing the
* same `Clock` instance advance in lockstep and resolve their timers at
* exactly the same point on every run — no matter how fast or slow the
* machine is.
*
* @example
* Share a single clock between two timelines so their timers line up:
* ```typescript
* const clock = new Clock()
* const source = Timeline.create('--1--2------', { clock })
* const expected = Timeline.create('-----T10-2--', { clock })
* ```
*/
export class Clock implements Clockable {
#now = 0
#waiters: { at: number; resolve: () => void }[] = []

/**
* The current virtual time, measured in frames.
*/
get now() {
return this.#now
}

/**
* Returns a promise that resolves once the clock has advanced by
* `frames` frames. A non-positive `frames` resolves immediately.
*/
wait(frames: number): Promise<void> {
if (frames <= 0) return Promise.resolve()
const at = this.#now + frames
return new Promise<void>((resolve) => {
this.#waiters.push({ at, resolve })
})
}

/**
* Advances virtual time by `frames` (default 1), resolving any
* waiters whose target frame has now been reached.
*/
advance(frames = 1) {
this.#now += frames
const due = this.#waiters.filter((waiter) => waiter.at <= this.#now)
this.#waiters = this.#waiters.filter((waiter) => waiter.at > this.#now)
for (const waiter of due) waiter.resolve()
}
}
Loading