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
6,571 changes: 4,639 additions & 1,932 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"build:docs": "npm run build:docs:api && npm run build:docs:site",
"build:docs:api": "typedoc",
"build:docs:site": "bundle exec jekyll build --config jekyll.yml",
"clean": "tsc --build --clean",
"clean": "tsc --build --clean && rm -r packages/*/dist",
"lint": "oxlint",
"prepare": "husky",
"scaffold": "plop --plopfile=scaffold/plopfile.ts",
Expand All @@ -27,14 +27,14 @@
"test": "npm run --workspaces test"
},
"devDependencies": {
"@commitlint/cli": "21.0.1",
"@commitlint/config-conventional": "21.0.1",
"@commitlint/cli": "21.0.2",
"@commitlint/config-conventional": "21.0.2",
"@commitlint/types": "21.0.1",
"@types/node": "^25.9.1",
"concurrently": "9.2.1",
"concurrently": "10.0.3",
"husky": "9.1.7",
"lint-staged": "17.0.5",
"oxlint": "1.67.0",
"lint-staged": "17.0.7",
"oxlint": "1.68.0",
"plop": "4.0.5",
"plop-pack-remove": "1.1.0",
"prettier": "3.8.3",
Expand Down
36 changes: 25 additions & 11 deletions packages/stream-assert/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
import { expectTimeline, fromTimeline } from '@johngw/stream-test'
import {
type Clockable,
expectTimeline,
fromTimeline,
} from '@johngw/stream-test'
import assert from 'node:assert'

export { FakeClock } from '@johngw/stream-test'
export { fromTimeline }

export interface AssertTimelineOptions {
message?: string
clock?: Clockable
}

export function assertTimeline<T>(
stream: ReadableStream<T>,
outputTimeline: string,
message?: string,
options?: AssertTimelineOptions,
) {
return stream.pipeTo(
expectTimeline(outputTimeline, (timelineValue, chunk, timeline) => {
try {
assert.deepStrictEqual(chunk, timelineValue, message)
} catch (error: any) {
error.message =
timeline.displayTimelinePosition() + '\n' + error.message
throw error
}
}),
expectTimeline(
outputTimeline,
(timelineValue, chunk, timeline) => {
try {
assert.deepStrictEqual(chunk, timelineValue, options?.message)
} catch (error: any) {
error.message =
timeline.displayTimelinePosition() + '\n' + error.message
throw error
}
},
options,
),
)
}
60 changes: 36 additions & 24 deletions packages/stream-jest/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,63 @@
import {
expectTimeline as $expectTimeline,
type ExpectTimelineOptions,
type ParsedTimelineItemValue,
} from '@johngw/stream-test'
import { expect, JestAssertionError, type MatcherContext } from 'expect'
import type { StreamPipeOptions } from 'node:stream/web'

export { FakeClock } from '@johngw/stream-test'
export { fromTimeline } from '@johngw/stream-test'

export function expectTimeline(timeline: string) {
return $expectTimeline(timeline, (timelineValue, chunk, timeline) => {
try {
expect(chunk).toStrictEqual(timelineValue)
} catch (error: any) {
error.message = timeline.displayTimelinePosition() + '\n' + error.message
throw error
}
})
export function expectTimeline<T>(
timeline: string,
options?: ExpectTimelineOptions<T>,
) {
return $expectTimeline<T>(
timeline,
(timelineValue, chunk, timeline) => {
try {
expect(chunk).toStrictEqual(timelineValue)
} catch (error: any) {
error.message =
timeline.displayTimelinePosition() + '\n' + error.message
throw error
}
},
options,
)
}

expect.extend({
toMatchTimeline: function toMatchTimeline<T extends ParsedTimelineItemValue>(
this: MatcherContext,
stream: ReadableStream<T>,
timeline: string,
streamPipeOptions?: StreamPipeOptions,
streamPipeOptions?: StreamPipeOptions & ExpectTimelineOptions<unknown>,
) {
return stream.pipeTo(expectTimeline(timeline), streamPipeOptions).then(
() => ({
message: () =>
`expect ${this.utils.printExpected(
stream,
)} to match timeline ${timeline}`,
pass: true,
}),
(error: JestAssertionError) => ({
message: () => error.matcherResult?.message || error.message,
pass: error.matcherResult?.pass || false,
}),
)
return stream
.pipeTo(expectTimeline(timeline, streamPipeOptions), streamPipeOptions)
.then(
() => ({
message: () =>
`expect ${this.utils.printExpected(
stream,
)} to match timeline ${timeline}`,
pass: true,
}),
(error: JestAssertionError) => ({
message: () => error.matcherResult?.message || error.message,
pass: error.matcherResult?.pass || false,
}),
)
},
})

declare module 'expect' {
interface Matchers<R> {
toMatchTimeline(
timeline: string,
streamPipeOptions?: StreamPipeOptions,
streamPipeOptions?: StreamPipeOptions & ExpectTimelineOptions<R>,
): Promise<R>
}
}
11 changes: 6 additions & 5 deletions packages/stream-test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@
"typescript": "6.0.3"
},
"dependencies": {
"@johngw/stream-common": "2.1.0",
"@johngw/timeline": "4.0.1",
"@johngw/stream-common": "^2.1.0",
"@johngw/timeline": "^5.0.0",
"@sinonjs/fake-timers": "^15.4.0",
"@types/node": "^25.0.0",
"assert-never": "1.4.0",
"js-yaml": "4.1.1",
"tslib": "2.8.1"
"assert-never": "^1.4.0",
"js-yaml": "^4.1.1",
"tslib": "^2.8.1"
}
}
60 changes: 60 additions & 0 deletions packages/stream-test/src/FakeClock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Clock, setDefaultClock } from '@johngw/timeline/Clock'
import FakeTimers from '@sinonjs/fake-timers'

export interface Uninstallable {
uninstall(): void
}

export class FakeClock extends Clock implements Uninstallable {
static #instance?: FakeClock

static install() {
if (!this.#instance) this.#instance = new FakeClock()
return this.#instance
}

static uninstall() {
this.#instance?.uninstall()
this.#instance = undefined
}

#fake = FakeTimers.install({
toFake: [
'setInterval',
'clearInterval',
'setTimeout',
'clearTimeout',
'Date',
],
})

#uninstall = setDefaultClock(this)

private constructor() {
super()
// Auto-advance to the next pending timer whenever the event loop would
// otherwise be idle. This breaks deadlocks where time can only move via
// a fake timer that nothing is actively driving — e.g. a CachableSource
// waiting out its cache TTL while its timeline source isn't being pulled.
this.#fake.setTickMode({ mode: 'nextAsync' })
}

override get now() {
return this.#fake.now
}

override wait(frames: number) {
return new Promise<void>((resolve) => {
this.#fake.setTimeout(() => resolve(), frames)
})
}

override async advance(frames = 1) {
for (let i = 0; i < frames; i++) await this.#fake.tickAsync(1)
}

uninstall() {
this.#uninstall()
this.#fake.uninstall()
}
}
41 changes: 36 additions & 5 deletions packages/stream-test/src/expectTimeline.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { timeout } from '@johngw/stream-common/Async'
import { type Clockable, getDefaultClock } from '@johngw/timeline/Clock'
import {
type ParsedTimelineItem,
type ParsedTimelineItemValue,
Expand All @@ -18,6 +18,30 @@ import { TimelineItemBoolean } from '@johngw/timeline/TimelineItemBoolean'
import { TimelineItemNull } from '@johngw/timeline/TimelineItemNull'
import { assertNever } from 'assert-never'

export interface ExpectTimelineOptions<T> {
clock?: Clockable
queuingStrategy?: QueuingStrategy<T>
}

/**
* A read-only view of a clock: it reads the time but never advances it.
*
* @remarks
* The expectation shares the source's clock so its timers see the same
* virtual time, but only the source should *drive* that clock. Without this
* both timelines' dashes/timers would advance the shared clock and time
* would move at ~2x.
*/
function readonlyClock(clock: Clockable): Clockable {
return {
get now() {
return clock.now
},
wait: (frames) => clock.wait(frames),
advance: () => {},
}
}

/**
* Calls an expectation function to compare a timeline against chunks.
*
Expand Down Expand Up @@ -45,9 +69,13 @@ export function expectTimeline<T extends ParsedTimelineItemValue>(
chunk: unknown,
timeline: Timeline,
) => void | Promise<void>,
queuingStrategy?: QueuingStrategy<T>,
options?: ExpectTimelineOptions<T>,
) {
const timeline = Timeline.create(timelineString)
// Read-only view of the shared clock (explicit, else the ambient one the
// source drives) so the expectation reads time without advancing it.
const timeline = Timeline.create(timelineString, {
clock: readonlyClock(options?.clock ?? getDefaultClock()),
})
let nextResult: Promise<IteratorResult<ParsedTimelineItem, undefined>>

return new WritableStream<T>(
Expand Down Expand Up @@ -82,7 +110,10 @@ export function expectTimeline<T extends ParsedTimelineItemValue>(
return controller.error(value.get())
} else if (value instanceof TimelineItemTimer) {
const timer = value.get()
await timeout()
// Yield with a microtask, not a (possibly faked) timer, so we
// can't deadlock against the fake clock; then assert enough
// virtual time has elapsed.
await Promise.resolve()
if (!timer.finished)
controller.error(new TimelineTimerError(timeline, timer))
nextResult = next(controller)
Expand Down Expand Up @@ -112,7 +143,7 @@ export function expectTimeline<T extends ParsedTimelineItemValue>(
nextResult = next(controller)
},
},
queuingStrategy,
options?.queuingStrategy,
)

async function next(
Expand Down
17 changes: 13 additions & 4 deletions packages/stream-test/src/fromTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import { TimelineItemInstance } from '@johngw/timeline/TimelineItemInstance'
import { TimelineItemNeverReach } from '@johngw/timeline/TimelineItemNeverReach'
import { TimelineItemNull } from '@johngw/timeline/TimelineItemNull'
import { TimelineItemTimer } from '@johngw/timeline/TimelineItemTimer'
import type { Clockable } from '@johngw/timeline/Clock'

export interface FromTimelineOptions<T> {
clock?: Clockable
queuingStrategy?: QueuingStrategy<T>
}

/**
* Creates a ReadableStream from a "timeline".
Expand Down Expand Up @@ -48,9 +54,9 @@ import { TimelineItemTimer } from '@johngw/timeline/TimelineItemTimer'
*/
export function fromTimeline<T extends ParsedTimelineItemValue>(
timelineString: string,
queuingStrategy?: QueuingStrategy<T>,
options?: FromTimelineOptions<T>,
): ReadableStream<T> {
const timeline = Timeline.create(timelineString)
const timeline = Timeline.create(timelineString, options)

return new ReadableStream<T>(
{
Expand All @@ -67,7 +73,10 @@ export function fromTimeline<T extends ParsedTimelineItemValue>(
) {
return controller.error(value.get())
} else if (value instanceof TimelineItemTimer) {
await value.get().promise
// Passing the timer advances the clock by its full duration
// (see TimelineItemTimer.onPass), so just move on. Awaiting the
// timer's promise here would deadlock on a virtual clock —
// nothing advances the clock while we're blocked on it.
return this.pull!(controller)
} else if (value instanceof TimelineItemInstance) {
const Class = new Function(`return class ${value.get().name} {}`)()
Expand All @@ -83,6 +92,6 @@ export function fromTimeline<T extends ParsedTimelineItemValue>(
}
},
},
queuingStrategy,
options?.queuingStrategy,
)
}
2 changes: 2 additions & 0 deletions packages/stream-test/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export * from '@johngw/stream-common/Test'
export * from './expectTimeline.js'
export * from './fromTimeline.js'
export * from './FakeClock.js'
export { type Clockable } from '@johngw/timeline/Clock'
export {
type ParsedTimelineItem,
type ParsedTimelineItemValue,
Expand Down
Loading