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});
82
83test('fresh home rows expire after a minute; later swaps still prepend and get a full minute', (t) => {
84 const { live } = setup(t);
85 let current = null, next = null;
86 let prepends = 0;
87 const rows = { dataset: { sort: 'activity', desc: 'true' }, prepend(el) {
88 prepends++;
89 current = el;
90 el.parentElement = this;
91 } };
92 const fresh = () => {
93 const attrs = new Set(['data-activity']);
94 const pill = { removed: false, remove() { this.removed = true; } };
95 return { id: 'repo-demo', pill, parentElement: rows,
96 querySelectorAll: () => [],
97 hasAttribute: (name) => attrs.has(name),
98 removeAttribute: (name) => attrs.delete(name),
99 querySelector: (sel) => sel === '.repo-activity' && !pill.removed ? pill : null,
100 replaceWith(el) { current = el; el.parentElement = rows; },
101 };
102 };
103 document.createElement = () => ({ set innerHTML(_) {}, content: { get children() { return [next]; } } });
104 document.getElementById = (id) => id === 'repo-rows' ? rows : current;
105 document.querySelectorAll = () => [];
106
107 next = fresh();
108 live.swap('first');
109 const first = current;
110 assert.equal(prepends, 1, 'a newly added repository is prepended');
111 t.mock.timers.tick(30000);
112 next = fresh();
113 live.swap('second');
114 const second = current;
115 assert.equal(prepends, 2, 'later activity reorders the existing row');
116 t.mock.timers.tick(30000);
117 assert.equal(first.pill.removed, true);
118 assert.equal(second.pill.removed, false, 'the old timer cannot remove the new badge');
119 t.mock.timers.tick(29999);
120 assert.equal(second.pill.removed, false);
121 t.mock.timers.tick(1);
122 assert.equal(second.pill.removed, true, 'removal also removes it from the accessibility tree');
123 assert.equal(second.hasAttribute('data-activity'), false);
124 next = fresh();
125 live.swap('third');
126 assert.equal(prepends, 3, 'activity still reorders after the previous badge expired');
127 assert.equal(current.pill.removed, false);
128 t.mock.timers.tick(60000);
129 assert.equal(current.pill.removed, true);
130});