detached fda3c2b

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

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