{"id":50736527,"url":"https://github.com/jg-wright/timeline","last_synced_at":"2026-06-10T14:02:12.341Z","repository":{"id":173321834,"uuid":"650546732","full_name":"jg-wright/timeline","owner":"jg-wright","description":"Parse a stream-like timeline of values","archived":false,"fork":false,"pushed_at":"2026-06-05T10:47:51.000Z","size":209592,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2026-06-05T12:13:37.818Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/jg-wright.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2023-06-07T09:50:11.000Z","updated_at":"2026-06-05T10:47:30.000Z","dependencies_parsed_at":"2025-03-30T10:23:55.663Z","dependency_job_id":"f3544b99-56fc-4fd9-8698-0a4490f73b07","html_url":"https://github.com/jg-wright/timeline","commit_stats":null,"previous_names":["johngeorgewright/timeline","jg-wright/timeline"],"tags_count":19,"template":false,"template_full_name":"johngeorgewright/ts-module","purl":"pkg:github/jg-wright/timeline","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jg-wright%2Ftimeline","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jg-wright%2Ftimeline/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jg-wright%2Ftimeline/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jg-wright%2Ftimeline/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/jg-wright","download_url":"https://codeload.github.com/jg-wright/timeline/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jg-wright%2Ftimeline/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34155422,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-10T02:00:07.152Z","response_time":89,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-10T14:02:11.608Z","updated_at":"2026-06-10T14:02:12.334Z","avatar_url":"https://github.com/jg-wright.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @johngw/timeline\n\n\u003e Parse a stream-like timeline of values.\n\nA timeline is the way to describe and test values over a period of time. For example, consider the following:\n\n```\n--1--2--3--4--\n```\n\nThe above is a stream-like set of values that queues 1, 2, 3, 4.\n\nThe following is an example of merging 2 streams together and what the result would be after.\n\n```\nmerge([\n--1---2---3---4--\n----a---b---c----\n])\n\n--1-a-2-b-3-c-4--\n```\n\n## Use\n\nUse the `Timeline` class to generate an `AsncyIterator` of timeline values.\n\n```javascript\nconst timeline = Timeline.create(`\n  --1--{foo: bar}--[a,b]--true--T--false--F--null--N--E--E(err foo)--\u003cDate\u003e--T10--X-|\n`)\n\nfor await (const value of timeline) {\n  // Customise the handling of your values here\n}\n```\n\n## Consistent timing\n\nTimelines 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.\n\nTo 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:\n\n```javascript\nimport { Clock, Timeline } from '@johngw/timeline'\n\nconst clock = new Clock()\nconst source = Timeline.create('--1--2------', { clock })\nconst expected = Timeline.create('-----T10-2--', { clock })\n```\n\nIf you don't pass a clock, each `Timeline` gets its own fresh one.\n\n### Driving real timers (e.g. testing a transformer)\n\nThe `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:\n\n```typescript\nexport function sampleTime\u003cT\u003e(ms: number): TransformStream\u003cT, T\u003e {\n  let buffer: T\n  let hasSample = false\n  let interval: ReturnType\u003ctypeof setInterval\u003e\n\n  return new TransformStream({\n    start(controller) {\n      interval = setInterval(() =\u003e {\n        if (hasSample) controller.enqueue(buffer)\n      }, ms)\n    },\n    transform(chunk) {\n      hasSample = true\n      buffer = chunk\n    },\n    flush: () =\u003e clearInterval(interval),\n    cancel: () =\u003e clearInterval(interval),\n  })\n}\n```\n\nIf 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.\n\nA `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_:\n\n```typescript\nimport FakeTimers from '@sinonjs/fake-timers'\nimport { Timeline, type Clockable } from '@johngw/timeline'\n\n// 1. Make the transformer's setInterval/setTimeout/Date controllable.\nconst fake = FakeTimers.install({\n  toFake: [\n    'setInterval',\n    'clearInterval',\n    'setTimeout',\n    'clearTimeout',\n    'Date',\n  ],\n})\n\ntry {\n  // 2. A Clockable backed by the *same* fake clock the transformer is on.\n  //    One frame == one fake millisecond.\n  const clock: Clockable = {\n    get now() {\n      return fake.now\n    },\n    wait: (frames) =\u003e\n      new Promise((resolve) =\u003e {\n        fake.setTimeout(resolve, frames)\n      }),\n    // `tickAsync` advances fake time *and* drains the promises the stream\n    // pipeline schedules, so an interval's `enqueue` surfaces before the\n    // next frame.\n    advance: (frames = 1) =\u003e fake.tickAsync(frames).then(() =\u003e {}),\n  }\n\n  // 3. Consuming a dash now ticks the same clock sampleTime's interval is on,\n  //    so `T20` and `setInterval(20)` fire together — deterministically.\n  const source = Timeline.create('1-T40--------2--T20--|', { clock })\n  const expected = Timeline.create('T20-1-T20-1-T20-2---', { clock })\n\n  // …drive `source` into `sampleTime(20)` and assert against `expected`.\n} finally {\n  fake.uninstall()\n}\n```\n\nThe 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).\n\n## Examples\n\nSee real-world examples in the `@johngw/stream-test` package:\n\n- https://github.com/johngeorgewright/stream/blob/main/packages/stream-test/src/fromTimeline.ts\n- https://github.com/johngeorgewright/stream/blob/main/packages/stream-test/src/expectTimeline.ts\n\n## Syntax\n\nThe syntax for timelines are as follows:\n\n### Closing a stream\n\nA stream will only close, when specified to do so, with the pipe character: `|`.\n\nFor example:\n\n```\n--1--2--3--4--|\n```\n\n### Errors\n\nAn error can be populated downstream with the capital letter `E` and an optional message inside paranthesis: `E(my message)`.\n\n### Never\n\nSometimes you may want to create an expectation that the timeline should **never** reach. Use the capital `X` for such a scenario.\n\nFor example, the `buffer` transformer's test uses this to test that the buffer's `notifier` close event will close the source stream:\n\n```\n--1--2--3---X\n\nbuffer(\n--------|\n)\n\n--------[1,2,3]\n```\n\n### Timers\n\nTo signal waiting for a period of time, use a capital `T` followed by a number, representing the amount of time to wait for.\n\nFor example:\n\n```\n--1--2------\n\ndebounce(10)\n\n-----T10-2--\n```\n\nTiming 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.\n\n### Null\n\nAlthough the keyword `null` can be used, a shorter `N` can also be used.\n\n### Booleans\n\nAlthought the keywords `true` \u0026 `false` can be used, the shorter versions `T` \u0026 `F` can also be used.\n\n### Instances\n\nAlthough we cannot actually provide instances through a timeline string, we can represent one. Use `\u003cInstanceName\u003e` and receive a `TimelineInstanceOf\u003c{ InstanceName }\u003e` object.\n\n### Numbers, Strings, Boolean, Objects \u0026 Arrays\n\nAny combination of characters, other than a dash (`-`) or any of the above syntax, will be parsed by [js-yaml](https://github.com/nodeca/js-yaml).\n\n## Customizing\n\nYou have the ability to add your own timeline items.\n\n### Creating the parser\n\nEach timeline item must have a parser. It should take from **the beginning** of a given timeline string and returning a binary tuple where the first value is an instance of the timeline item and the second value is the remaining timeline string.\n\n```typescript\nimport { outerface } from '@johngw/outerface'\nimport {\n  TimelineItem,\n  TimelineItemOptions,\n  TimelineParsable,\n} from '@johngw/timeline/TimelineItem'\n\n@outerface\u003cTimelineParsable\u003cFooBarTimelineItem\u003e\u003e()\nexport class FooBarTimelineItem extends TimelineItem\u003cstring\u003e {\n  static parse(timeline: string, options: TimelineItemOptions) {\n    const result = this.createItemRegExp('(FOO)').exec(timeline)\n    return result\n      ? [\n          new FooBarTimelineItem(result[1], options),\n          timeline.slice(result[1].length),\n        ]\n      : undefined\n  }\n}\n```\n\nIf your parser returns `undefined` it the iterator will keep moving on to the following parsers until it receives a tuple.\n\nNow we need to implement the rest of the `TimelineItem`.\n\n```typescript\nimport { outerface } from '@johngw/outerface'\nimport {\n  TimelineItem,\n  TimelineItemOptions,\n  TimelineParsable,\n} from '@johngw/timeline/TimelineItem'\n\n@outerface\u003cTimelineParsable\u003cFooBarTimelineItem\u003e\u003e()\nexport class FooBarTimelineItem extends TimelineItem\u003cstring\u003e {\n  static parse(timeline: string, options: TimelineItemOptions) {\n    const result = this.createItemRegExp('(FOO)').exec(timeline)\n    return result\n      ? [\n          new FooBarTimelineItem(result[1], options),\n          timeline.slice(result[1].length),\n        ]\n      : undefined\n  }\n\n  get() {\n    return 'BAR'\n  }\n}\n```\n\n`FooBarTimelineItem` will now be used whenever there is `'FOO'` in the timeline. The value, however, will be `'BAR'`.\n\n```typescript\nconst timeline = Timeline.create('--1--2--FOO--', [FooBarTimelineItem])\n\nlet output = ''\nfor await (const item of timeline) {\n  const value = item.get()\n  output += value === undefined ? '-' : value\n}\n\nconsole.info(output)\n// '--1--2--BAR--'\n```\n\n### Lifecycle Hooks\n\nThere are lifecycle methods to implement if you wish to hook in to the timeline iterator.\n\n#### `onReach`\n\nThis method is called when a timeline item is reached.\n\n```typescript\nimport { outerface } from '@johngw/outerface'\nimport {\n  TimelineItem,\n  TimelineItemOptions,\n  TimelineParsable,\n} from '@johngw/timeline/TimelineItem'\n\n@outerface\u003cTimelineParsable\u003cFooBarTimelineItem\u003e\u003e()\nexport class FooBarTimelineItem extends TimelineItem\u003cstring\u003e {\n  static parse(timeline: string, options: TimelineItemOptions) {\n    const result = this.createItemRegExp('(FOO)').exec(timeline)\n    return result\n      ? [\n          new FooBarTimelineItem(result[1], options),\n          timeline.slice(result[1].length),\n        ]\n      : undefined\n  }\n\n  get() {\n    return 'BAR'\n  }\n\n  override onReach() {\n    console.info('Foo has happened')\n    return super.onReach()\n  }\n}\n```\n\n#### `onPass`\n\nA method that is called just before reaching the next item.\n\n```typescript\nimport { outerface } from '@johngw/outerface'\nimport {\n  TimelineItem,\n  TimelineItemOptions,\n  TimelineParsable,\n} from '@johngw/timeline/TimelineItem'\n\n@outerface\u003cTimelineParsable\u003cFooBarTimelineItem\u003e\u003e()\nexport class FooBarTimelineItem extends TimelineItem\u003cstring\u003e {\n  static parse(timeline: string, options: TimelineItemOptions) {\n    const result = this.createItemRegExp('(FOO)').exec(timeline)\n    return result\n      ? [\n          new FooBarTimelineItem(result[1], options),\n          timeline.slice(result[1].length),\n        ]\n      : undefined\n  }\n\n  get() {\n    return 'BAR'\n  }\n\n  override onPass() {\n    console.info('Successfully passed the foo item')\n    return super.onPass()\n  }\n}\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjg-wright%2Ftimeline","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjg-wright%2Ftimeline","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjg-wright%2Ftimeline/lists"}