main 1fb2421

live.test.mjs RawHistoryFolder

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});

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close