1// Tests of the "live updates paused" delay of static/js/ermite.js
2// (window.ermiteLive.lost/back) with mocked timers. Only the ermiteLive part of
3// the file is evaluated: the rest needs a page. Run with
4// node --test 'internal/adapters/web/jstest/*.test.mjs'
5import assert from 'node:assert/strict';
6import { readFileSync } from 'node:fs';
7import { test } from 'node:test';
8
9const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
10const live = src.slice(src.indexOf('window.ermiteLive = '));
11
12function setup(t) {
13 t.mock.timers.enable({ apis: ['setTimeout'] });
14 const hint = { dataset: { state: 'connecting' } };
15 const listeners = {};
16 globalThis.window = { addEventListener: (name, fn) => { listeners[name] = fn; } };
17 globalThis.document = { querySelector: (sel) => (sel === '.hint-live' ? hint : null) };
18 new Function(live)();
19 return { live: globalThis.window.ermiteLive, hint, listeners };
20}
21
22test('a drop shorter than the delay says nothing', (t) => {
23 const { live, hint } = setup(t);
24 live.back();
25 live.lost();
26 t.mock.timers.tick(4000);
27 live.back();
28 t.mock.timers.tick(10000);
29 assert.equal(hint.dataset.state, 'live');
30});
31
32test('a drop that lasts shows offline, once, until the stream is back', (t) => {
33 const { live, hint } = setup(t);
34 live.lost();
35 t.mock.timers.tick(3000);
36 live.lost(); // a second error does not restart the delay
37 t.mock.timers.tick(2000);
38 assert.equal(hint.dataset.state, 'offline');
39 live.back();
40 assert.equal(hint.dataset.state, 'live');
41});
42
43test('stream teardown handles the reader cancellation that hx-sse leaves uncaught', async (t) => {
44 const { live, listeners } = setup(t);
45 assert.equal(listeners.unhandledrejection, undefined, 'no global rejection filter');
46 const abort = Object.assign(new Error('signal is aborted without reason'), { name: 'AbortError' });
47 let cancelled = 0;
48 const connection = { reader: { cancel: () => { cancelled++; return Promise.reject(abort); } } };
49 listeners['htmx:sse:after:connection']({ target: { matches: () => true }, detail: { connection } });
50 const escaped = [];
51 const onRejection = (reason) => escaped.push(reason);
52 process.on('unhandledRejection', onRejection);
53 try {
54 // remove stands for hx-sse's cleanup: r.reader?.cancel?.(), result ignored.
55 await live.teardown(() => { connection.reader?.cancel?.(); });
56 await new Promise((resolve) => setImmediate(resolve));
57 await new Promise((resolve) => setImmediate(resolve));
58 } finally {
59 process.off('unhandledRejection', onRejection);
60 }
61 assert.equal(cancelled, 1, 'the real reader is still cancelled');
62 assert.deepEqual(escaped, [], 'no AbortError escapes the teardown');
63});
64
65test('replacement stream URL carries the last connection id, including id-only events', (t) => {
66 const { live, listeners } = setup(t);
67 const connection = { lastEventId: 'boot-7' };
68 listeners['htmx:sse:after:connection']({ target: { matches: () => true }, detail: { connection } });
69 connection.lastEventId = 'boot-8'; // hx-sse updates this even for id-only events.
70 const saved = globalThis.location;
71 globalThis.location = { href: 'https://forge.test/demo/merge-requests/1' };
72 const element = { url: '/-/events?topic=repo%3Ademo&head=new', getAttribute() { return this.url; },
73 setAttribute(name, value) { assert.equal(name, 'hx-sse:connect'); this.url = value; } };
74 try {
75 live.resumeStream(element);
76 const url = new URL(element.url, location.href);
77 assert.equal(url.searchParams.get('after'), 'boot-8');
78 assert.equal(url.searchParams.get('head'), 'new');
79 assert.equal(url.searchParams.get('topic'), 'repo:demo');
80 } finally { globalThis.location = saved; }
81});