SPB Git

Repository Public

stream.test.ts 22.3 KB 386 lines typescript

1// Hyperliquid websocket client with an injected WebSocket: real messages recorded from mainnet / testnet (fixtures *-ws.json),2// simulated disconnects, reconnects, duplicate deliveries, silence and malformed messages. No network; fake timers.3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';4import { BookSchema, FillSchema, PublicTradeSchema, TickerSchema, type Fill, type PublicTrade } from '../../src/index.ts';5import { createHlStream, createHyperliquidAdapter, type HlStreamEvent, type HlStreamStatus, type HlWebSocketLike } from '../../src/venues/hyperliquid/index.ts';6import { fx, raw, replay } from './helpers.ts';78class FakeSocket implements HlWebSocketLike {9  static all: FakeSocket[] = [];10  static failNext = 0;11  onopen: ((ev: unknown) => void) | null = null; onmessage: ((ev: { data: unknown }) => void) | null = null;12  onclose: ((ev: unknown) => void) | null = null; onerror: ((ev: unknown) => void) | null = null;13  sent: any[] = []; closed = false; readonly url: string;14  constructor(url: string) {15    if (FakeSocket.failNext > 0) { FakeSocket.failNext -= 1; throw new Error('connection refused'); }16    this.url = url; FakeSocket.all.push(this);17  }18  send(data: string): void { if (this.closed) throw new Error('socket is closed'); this.sent.push(JSON.parse(data)); }19  close(): void { this.closed = true; }20  // --- test controls ---21  open(): void { this.onopen?.({}); }22  push(msg: unknown): void { this.onmessage?.({ data: typeof msg === 'string' ? msg : JSON.stringify(msg) }); }23  /** the server (or the network) drops the connection */24  drop(): void { this.closed = true; this.onerror?.({}); this.onclose?.({ code: 1006 }); }25  subs(method = 'subscribe'): any[] { return this.sent.filter((m) => m.method === method).map((m) => m.subscription); }26}27const last = () => FakeSocket.all.at(-1)!;2829const ws = raw('mainnet-ws.json') as { subscriptions: any[]; messages: any[] };30const msg = (channel: string, n = 0) => structuredClone(ws.messages.filter((m) => m.channel === channel)[n]);31const USER = ws.subscriptions.find((s) => s.type === 'userFills').user as string;3233function make(o: Partial<Parameters<typeof createHlStream>[0]> = {}) {34  const stream = createHlStream({ network: 'mainnet', WebSocket: FakeSocket, random: () => 1, ...o });35  const listen = () => { const events: HlStreamEvent[] = [], status: HlStreamStatus[] = []; return { events, status, on: (e: unknown) => { events.push(e as HlStreamEvent); }, onStatus: (s: HlStreamStatus) => { status.push(s); } }; };36  return { stream, listen };37}3839beforeEach(() => { vi.useFakeTimers({ now: 1_794_366_000_000 }); FakeSocket.all = []; FakeSocket.failNext = 0; });40afterEach(() => { vi.useRealTimers(); });4142describe('hyperliquid stream — recorded messages', () => {43  it('connects lazily to the documented URL and subscribes', () => {44    const { stream, listen } = make();45    expect(FakeSocket.all.length).toBe(0);46    const l = listen();47    stream.subscribe({ type: 'book', symbol: 'BTC' }, l.on, l.onStatus);48    expect(last().url).toBe('wss://api.hyperliquid.xyz/ws');49    expect(l.status).toEqual([]);50    last().open();51    expect(l.status).toEqual(['connected']);52    expect(last().sent).toEqual([{ method: 'subscribe', subscription: { type: 'l2Book', coin: 'BTC' } }]);53    expect(createHlStream({ network: 'testnet', WebSocket: FakeSocket }).stats().status).toBe('idle');54  });5556  it('book, trades, ticker and mids from real messages', () => {57    const { stream, listen } = make();58    const book = listen(), trades = listen(), ticker = listen(), mids = listen();59    stream.subscribe({ type: 'book', symbol: 'BTC' }, book.on, book.onStatus);60    stream.subscribe({ type: 'trades', symbol: 'BTC' }, trades.on, trades.onStatus);61    stream.subscribe({ type: 'ticker', symbol: 'BTC' }, ticker.on, ticker.onStatus);62    stream.subscribe({ type: 'mids' }, mids.on, mids.onStatus);63    const s = last();64    s.open();65    expect(s.subs()).toEqual([{ type: 'l2Book', coin: 'BTC' }, { type: 'trades', coin: 'BTC' }, { type: 'activeAssetCtx', coin: 'BTC' }, { type: 'allMids' }]);66    for (const m of ws.messages) s.push(m); // the whole recorded session, acks, pong and error messages included6768    const b = book.events.filter((e) => e.type === 'book');69    expect(b.length).toBe(ws.messages.filter((m) => m.channel === 'l2Book').length);70    const rawBook = msg('l2Book').data, got = (b[0] as { book: unknown }).book as ReturnType<typeof BookSchema.parse>;71    BookSchema.parse(got);72    expect(got.ts).toBe(rawBook.time);73    expect(got.bids.length).toBe(rawBook.levels[0].length);74    expect(Number(got.bids[0]![0])).toBe(Number(rawBook.levels[0][0].px)); // display comparison only7576    const t = trades.events.filter((e) => e.type === 'trades').flatMap((e) => (e as { trades: PublicTrade[] }).trades);77    const rawTrades = ws.messages.filter((m) => m.channel === 'trades').flatMap((m) => m.data);78    expect(t.length).toBe(new Set(rawTrades.map((x: any) => `${x.time}-${x.tid}`)).size);79    for (const x of t) PublicTradeSchema.parse(x);8081    const k = ticker.events.filter((e) => e.type === 'ticker');82    expect(k.length).toBe(ws.messages.filter((m) => m.channel === 'activeAssetCtx').length);83    TickerSchema.parse((k[0] as { ticker: unknown }).ticker);84    expect((k[0] as any).ticker.instrument).toBe('hyperliquid:BTC');85    expect((k[0] as any).ticker.ts).toBe(1_794_366_000_000);8687    const m = mids.events.filter((e) => e.type === 'mids');88    expect(m.length).toBe(ws.messages.filter((x) => x.channel === 'allMids').length);89    expect(typeof (m[0] as any).mids.BTC === 'string' || Object.keys((m[0] as any).mids).length > 0).toBe(true);9091    // each consumer only sees its own feed; the venue's `error` messages are reported to everyone as invalid92    expect(book.events.every((e) => e.type === 'book' || e.type === 'invalid')).toBe(true);93    expect(book.events.filter((e) => e.type === 'invalid').length).toBe(ws.messages.filter((x) => x.channel === 'error').length);94    expect(stream.stats()).toMatchObject({ connects: 1, messages: ws.messages.length, subscriptions: 4, status: 'connected' });95  });9697  it('account feed: fills snapshot from a real message, then live fills, order updates, funding, venue cancels', () => {98    const { stream, listen } = make();99    const l = listen();100    stream.subscribe({ type: 'account', account: USER }, l.on, l.onStatus);101    const s = last();102    s.open();103    expect(s.subs()).toEqual([{ type: 'userFills', user: USER }, { type: 'orderUpdates', user: USER }, { type: 'userEvents', user: USER }]);104    const snap = msg('userFills');105    expect(snap.data.isSnapshot).toBe(true);106    s.push(snap);107    const first = l.events[0] as { type: 'fills'; fills: Fill[]; snapshot: boolean };108    expect(first.type).toBe('fills');109    expect(first.snapshot).toBe(true);110    expect(first.fills.length).toBe(snap.data.fills.length);111    for (const f of first.fills) FillSchema.parse(f);112113    // shapes below follow the documented WsOrder / WsUserEvent types (no such message occurred while recording)114    const fill = { ...snap.data.fills[0], tid: 987654321, hash: `0x${'ab'.repeat(32)}`, time: 1_794_366_000_500 };115    s.push({ channel: 'userFills', data: { user: USER, fills: [fill] } });116    s.push({ channel: 'user', data: { fills: [fill] } }); // the same fill again through userEvents117    s.push({ channel: 'orderUpdates', data: [118      { order: { coin: 'BTC', side: 'B', limitPx: '73000.0', sz: '0.001', oid: 91, timestamp: 1_794_366_000_100, origSz: '0.001', cloid: `0x${'01'.repeat(16)}` }, status: 'open', statusTimestamp: 1_794_366_000_100 },119      { order: { coin: 'BTC', side: 'B', limitPx: '73000.0', sz: '0.0004', oid: 91, timestamp: 1_794_366_000_100, origSz: '0.001' }, status: 'open', statusTimestamp: 1_794_366_000_200 },120      { order: { coin: 'BTC', side: 'A', limitPx: '75000.0', sz: '0.0', oid: 92, timestamp: 1_794_366_000_100, origSz: '0.002' }, status: 'filled', statusTimestamp: 1_794_366_000_300 },121      { order: { coin: 'ETH', side: 'A', limitPx: '2000.0', sz: '1.0', oid: 93, timestamp: 1_794_366_000_100, origSz: '1.0' }, status: 'reduceOnlyCanceled', statusTimestamp: 1_794_366_000_400 },122    ] });123    s.push({ channel: 'user', data: { funding: { time: 1_794_366_000_000, coin: 'BTC', usdc: '-0.0123', szi: '0.3', fundingRate: '0.0000125' } } });124    s.push({ channel: 'user', data: { nonUserCancel: [{ coin: 'ETH', oid: 93 }] } });125    s.push({ channel: 'user', data: { liquidation: { lid: 1, liquidator: '0x1', liquidated_user: USER, liquidated_ntl_pos: '1.0', liquidated_account_value: '0.5' } } });126127    const live = l.events.filter((e) => e.type === 'fills' && !e.snapshot) as { fills: Fill[] }[];128    expect(live.length).toBe(1); // delivered once, although it came on two channels129    expect(live[0]!.fills[0]).toMatchObject({ id: `0x${'ab'.repeat(32)}-987654321`, ts: 1_794_366_000_500 });130    const orders = (l.events.find((e) => e.type === 'orders') as { orders: any[] }).orders;131    expect(orders.map((o) => [o.venueOrderId, o.status, o.remaining])).toEqual([['91', 'open', '0.001'], ['91', 'partially_filled', '0.0004'], ['92', 'filled', '0'], ['93', 'cancelled', '1']]);132    expect(orders[0]).toMatchObject({ instrument: 'hyperliquid:BTC', side: 'buy', price: '73000', size: '0.001', clientId: `0x${'01'.repeat(16)}`, venueStatus: 'open' });133    expect(orders[3].venueStatus).toBe('reduceOnlyCanceled');134    expect(l.events.find((e) => e.type === 'funding')).toEqual({ type: 'funding', payment: { instrument: 'hyperliquid:BTC', amount: '-0.0123', rate: '0.0000125', size: '0.3', ts: 1_794_366_000_000 } });135    expect(l.events.find((e) => e.type === 'cancelled_by_venue')).toEqual({ type: 'cancelled_by_venue', orders: [{ instrument: 'hyperliquid:ETH', venueOrderId: '93' }] });136    expect(l.events.some((e) => e.type === 'liquidation')).toBe(true);137    expect(stream.stats().duplicates).toBe(1);138  });139140  it('one account per connection; a malformed address is refused', () => {141    const { stream, listen } = make();142    const l = listen();143    stream.subscribe({ type: 'account', account: USER }, l.on, l.onStatus);144    expect(() => stream.subscribe({ type: 'account', account: `0x${'1'.repeat(40)}` }, l.on, l.onStatus)).toThrow(/one account per stream/);145    expect(() => stream.subscribe({ type: 'account', account: 'nope' }, l.on, l.onStatus)).toThrow(/20-byte hex address/);146    expect(() => stream.subscribe({ type: 'book', symbol: '' }, l.on, l.onStatus)).toThrow(/bad symbol/);147  });148});149150describe('hyperliquid stream — heartbeat, silence, disconnects', () => {151  it('pings on the heartbeat period ({ "method": "ping" }) and stays connected while pongs arrive', () => {152    const { stream, listen } = make();153    const l = listen();154    stream.subscribe({ type: 'ticker', symbol: 'BTC' }, l.on, l.onStatus);155    const s = last();156    s.open();157    for (let i = 0; i < 8; i++) { vi.advanceTimersByTime(15_000); s.push({ channel: 'pong' }); }158    expect(s.sent.filter((m) => m.method === 'ping')).toEqual(Array.from({ length: 8 }, () => ({ method: 'ping' })));159    expect(l.status).toEqual(['connected']);160    expect(FakeSocket.all.length).toBe(1);161  });162163  it('silence → stale; traffic again on the same connection → connected, with no gap', () => {164    const { stream, listen } = make();165    const l = listen();166    stream.subscribe({ type: 'book', symbol: 'BTC' }, l.on, l.onStatus);167    const s = last();168    s.open();169    vi.advanceTimersByTime(26_000);170    expect(l.status).toEqual(['connected', 'stale']);171    s.push(msg('l2Book'));172    expect(l.status).toEqual(['connected', 'stale', 'connected']);173    expect(l.events.some((e) => e.type === 'gap')).toBe(false);174    expect(FakeSocket.all.length).toBe(1);175  });176177  it('prolonged silence → the connection is dropped and re-opened: reconnecting, connected, gap', () => {178    const { stream, listen } = make();179    const l = listen();180    stream.subscribe({ type: 'book', symbol: 'BTC' }, l.on, l.onStatus);181    const first = last();182    first.open();183    vi.advanceTimersByTime(36_500);184    expect(l.status).toEqual(['connected', 'stale', 'reconnecting']);185    expect(first.closed).toBe(true);186    vi.advanceTimersByTime(1000); // first backoff step (random = 1 → the full delay)187    expect(FakeSocket.all.length).toBe(2);188    last().open();189    expect(l.status).toEqual(['connected', 'stale', 'reconnecting', 'connected']);190    expect(l.events.at(-1)).toEqual({ type: 'gap', reason: 'reconnected' });191    expect(last().subs()).toEqual([{ type: 'l2Book', coin: 'BTC' }]);192    first.push(msg('l2Book')); // a late message from the abandoned socket is ignored193    expect(l.events.filter((e) => e.type === 'book').length).toBe(0);194  });195196  it('server-side disconnect: resubscribes everything and tells every consumer that a gap may exist', () => {197    const { stream, listen } = make();198    const a = listen(), b = listen(), c = listen();199    stream.subscribe({ type: 'book', symbol: 'BTC' }, a.on, a.onStatus);200    stream.subscribe({ type: 'trades', symbol: 'ETH' }, b.on, b.onStatus);201    stream.subscribe({ type: 'account', account: USER }, c.on, c.onStatus);202    last().open();203    last().push(msg('l2Book'));204    last().drop();205    expect([a.status, b.status, c.status]).toEqual([['connected', 'reconnecting'], ['connected', 'reconnecting'], ['connected', 'reconnecting']]);206    expect(FakeSocket.all.length).toBe(1);207    vi.advanceTimersByTime(999);208    expect(FakeSocket.all.length).toBe(1);209    vi.advanceTimersByTime(1);210    expect(FakeSocket.all.length).toBe(2);211    expect(a.events.some((e) => e.type === 'gap')).toBe(false); // not yet: nothing is connected212    last().open();213    expect(last().subs()).toEqual([{ type: 'l2Book', coin: 'BTC' }, { type: 'trades', coin: 'ETH' }, { type: 'userFills', user: USER }, { type: 'orderUpdates', user: USER }, { type: 'userEvents', user: USER }]);214    for (const l of [a, b, c]) { expect(l.status).toEqual(['connected', 'reconnecting', 'connected']); expect(l.events.filter((e) => e.type === 'gap')).toEqual([{ type: 'gap', reason: 'reconnected' }]); }215    last().push(msg('l2Book', 1));216    expect(a.events.map((e) => e.type)).toEqual(['book', 'gap', 'book']);217  });218219  it('backoff doubles up to the cap while the venue stays unreachable, and starts again after a connection that lasted', () => {220    const { stream, listen } = make({ backoffMinMs: 1000, backoffMaxMs: 8000 });221    const l = listen();222    stream.subscribe({ type: 'book', symbol: 'BTC' }, l.on, l.onStatus);223    last().open();224    last().drop();225    const delays: number[] = [];226    for (let i = 0; i < 6; i++) {227      const n = FakeSocket.all.length;228      let waited = 0;229      while (FakeSocket.all.length === n && waited < 60_000) { vi.advanceTimersByTime(250); waited += 250; }230      delays.push(waited);231      last().drop(); // accepted, then dropped at once (what the server does on a bad subscription)232    }233    expect(delays).toEqual([1000, 2000, 4000, 8000, 8000, 8000]);234    expect(l.status).toEqual(['connected', 'reconnecting']);235    // constructor failures count too236    FakeSocket.failNext = 2;237    vi.advanceTimersByTime(30_000);238    expect(last().closed).toBe(false);239    last().open();240    expect(l.status.at(-1)).toBe('connected');241    vi.advanceTimersByTime(10_000); last().push({ channel: 'pong' }); vi.advanceTimersByTime(1000);242    last().drop();243    const n = FakeSocket.all.length;244    vi.advanceTimersByTime(1000);245    expect(FakeSocket.all.length).toBe(n + 1); // back to the minimum delay246  });247248  it('jitter keeps the delay between half and the whole step', () => {249    const { stream, listen } = make({ random: () => 0 });250    const l = listen();251    stream.subscribe({ type: 'book', symbol: 'BTC' }, l.on, l.onStatus);252    last().open(); last().drop();253    vi.advanceTimersByTime(499);254    expect(FakeSocket.all.length).toBe(1);255    vi.advanceTimersByTime(1);256    expect(FakeSocket.all.length).toBe(2);257  });258259  it('duplicate deliveries: a fill or a trade is emitted once, also across a reconnect snapshot', () => {260    const { stream, listen } = make();261    const acct = listen(), trades = listen();262    stream.subscribe({ type: 'account', account: USER }, acct.on, acct.onStatus);263    stream.subscribe({ type: 'trades', symbol: 'BTC' }, trades.on, trades.onStatus);264    last().open();265    const snap = msg('userFills'), tr = msg('trades');266    last().push(snap); last().push(tr); last().push(tr); last().push(snap);267    const count = (l: ReturnType<typeof listen>, type: string, key: string) => l.events.filter((e) => e.type === type).reduce((n, e) => n + (e as any)[key].length, 0);268    expect(count(acct, 'fills', 'fills')).toBe(snap.data.fills.length);269    expect(count(trades, 'trades', 'trades')).toBe(new Set(tr.data.map((x: any) => `${x.time}-${x.tid}`)).size);270271    // reconnect: the snapshot comes again, with one fill that happened during the gap — only that one is emitted272    last().drop(); vi.advanceTimersByTime(1000); last().open();273    const missed = { ...snap.data.fills[0], tid: 555, hash: `0x${'cd'.repeat(32)}`, time: snap.data.fills[0].time + 1 };274    const again = structuredClone(snap);275    again.data.fills.unshift(missed);276    const before = acct.events.length;277    last().push(again);278    const after = acct.events.slice(before).filter((e) => e.type === 'fills') as { fills: Fill[]; snapshot: boolean }[];279    expect(after.length).toBe(1);280    expect(after[0]!.snapshot).toBe(true);281    expect(after[0]!.fills.map((f) => f.id)).toEqual([`0x${'cd'.repeat(32)}-555`]);282    last().push(tr);283    expect(count(trades, 'trades', 'trades')).toBe(new Set(tr.data.map((x: any) => `${x.time}-${x.tid}`)).size);284    expect(stream.stats().duplicates).toBeGreaterThan(0);285  });286287  it('malformed messages are dropped and reported, the stream goes on', () => {288    const { stream, listen } = make();289    const l = listen();290    stream.subscribe({ type: 'book', symbol: 'BTC' }, l.on, l.onStatus);291    const s = last();292    s.open();293    const crossed = msg('l2Book'); crossed.data.levels[0][0].px = '999999999';294    const numeric = msg('l2Book'); numeric.data.levels[1][0].px = 73700.5;295    for (const bad of ['not json', '{"no":"channel"}', { channel: 'l2Book', data: { coin: 'BTC' } }, crossed, numeric, { channel: 'l2Book' }, { channel: 'error', data: 'Already subscribed' }, 42, null]) s.push(bad as never);296    expect(l.events.every((e) => e.type === 'invalid')).toBe(true);297    expect(l.events.length).toBe(9);298    expect(stream.stats().invalid).toBe(9);299    s.push({ channel: 'somethingNew', data: {} }); // unknown channels and feeds we did not ask for are ignored silently300    s.push({ channel: 'l2Book', data: { ...msg('l2Book').data, coin: 'ETH' } });301    expect(l.events.length).toBe(9);302    s.push(msg('l2Book'));303    expect(l.events.at(-1)!.type).toBe('book');304    expect(l.status).toEqual(['connected']);305  });306307  it('a consumer that throws does not break the others', () => {308    const { stream, listen } = make();309    const good = listen();310    stream.subscribe({ type: 'book', symbol: 'BTC' }, () => { throw new Error('consumer bug'); }, () => { throw new Error('consumer bug'); });311    stream.subscribe({ type: 'book', symbol: 'BTC' }, good.on, good.onStatus);312    last().open(); last().push(msg('l2Book'));313    expect(good.events.map((e) => e.type)).toEqual(['book']);314    expect(last().subs().length).toBe(1); // one wire subscription for two consumers315  });316317  it('unsubscribe: the feed is released with the last consumer, the socket with the last feed', () => {318    const { stream, listen } = make();319    const a = listen(), b = listen(), c = listen();320    const ua = stream.subscribe({ type: 'book', symbol: 'BTC' }, a.on, a.onStatus), ub = stream.subscribe({ type: 'book', symbol: 'BTC' }, b.on, b.onStatus);321    const uc = stream.subscribe({ type: 'trades', symbol: 'BTC' }, c.on, c.onStatus);322    const s = last();323    s.open();324    ua(); ua();325    expect(s.subs('unsubscribe')).toEqual([]);326    s.push(msg('l2Book'));327    expect([a.events.length, b.events.length]).toEqual([0, 1]);328    ub();329    expect(s.subs('unsubscribe')).toEqual([{ type: 'l2Book', coin: 'BTC' }]);330    expect(s.closed).toBe(false);331    uc();332    expect(s.closed).toBe(true);333    expect(stream.stats().status).toBe('idle');334    vi.advanceTimersByTime(120_000);335    expect(FakeSocket.all.length).toBe(1); // no timer left, no reconnect336    // a new subscription starts a fresh connection, and it is a first connection again (no gap event)337    const d = listen();338    stream.subscribe({ type: 'book', symbol: 'BTC' }, d.on, d.onStatus);339    last().open();340    expect(d.events).toEqual([]);341    expect(d.status).toEqual(['connected']);342    stream.close();343    expect(last().closed).toBe(true);344  });345346  it('a subscription made while reconnecting learns the state and is included in the next connection', () => {347    const { stream, listen } = make();348    const a = listen(), b = listen();349    stream.subscribe({ type: 'book', symbol: 'BTC' }, a.on, a.onStatus);350    last().open(); last().drop();351    stream.subscribe({ type: 'ticker', symbol: 'ETH' }, b.on, b.onStatus);352    expect(b.status).toEqual(['reconnecting']);353    vi.advanceTimersByTime(1000); last().open();354    expect(last().subs()).toEqual([{ type: 'l2Book', coin: 'BTC' }, { type: 'activeAssetCtx', coin: 'ETH' }]);355    expect(b.status).toEqual(['reconnecting', 'connected']);356  });357});358359describe('hyperliquid adapter.stream', () => {360  it('checks the symbol against the venue table before subscribing (an unknown coin makes the server drop the connection)', async () => {361    vi.useRealTimers();362    FakeSocket.all = [];363    const r = replay({ meta: { body: fx('mainnet-meta').body } });364    const adapter = createHyperliquidAdapter({ network: 'mainnet', fetch: r.fetchImpl, stream: { WebSocket: FakeSocket } });365    const events: any[] = [], status: string[] = [];366    adapter.stream({ type: 'book', symbol: 'NOT-A-COIN' }, (e) => events.push(e), (s) => status.push(s));367    const stop = adapter.stream({ type: 'book', symbol: 'BTC' }, (e) => events.push(e), (s) => status.push(s));368    await new Promise((res) => setTimeout(res, 20));369    expect(events).toEqual([{ type: 'invalid', channel: 'subscribe', reason: "unknown instrument 'NOT-A-COIN'" }]);370    expect(FakeSocket.all.length).toBe(1);371    last().open();372    expect(last().subs()).toEqual([{ type: 'l2Book', coin: 'BTC' }]);373    last().push(msg('l2Book'));374    expect(events.at(-1).type).toBe('book');375    expect(status).toEqual(['connected']);376    stop();377    expect(last().closed).toBe(true);378    // cancelled before the table arrived: never subscribes379    const cancel = adapter.stream({ type: 'trades', symbol: 'ETH' }, () => {}, () => {});380    cancel();381    await new Promise((res) => setTimeout(res, 20));382    expect(FakeSocket.all.length).toBe(1);383    adapter.close();384  });385});386
Files main
Menu

Theme