v0.35.0 e86dbd6

mr.test.mjs RawHistoryFolder

1// Tests of the pure core of the merge request page (static/js/mr.js). Run with
2//   node --test 'internal/adapters/web/jstest/*.test.mjs'
3import assert from 'node:assert/strict';
4import { test } from 'node:test';
5
6globalThis.window = globalThis;
7globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };
8await import('../static/js/lazy.js');
9await import('../static/js/mr.js');
10const C = window.ermiteMR.core;
11
12test('windowOf: the start of the window of a file', () => {
13  assert.equal(C.windowOf('f1', 25), 0);
14  assert.equal(C.windowOf('f25', 25), 0);
15  assert.equal(C.windowOf('f26', 25), 25);
16  assert.equal(C.windowOf('f84', 25), 75);
17  assert.equal(C.windowOf('c3', 25), -1, 'not a file');
18  assert.equal(C.windowOf('f0', 25), -1);
19});
20
21test('menuKey: modes by number, D toggles the deletion, nothing else', () => {
22  assert.deepEqual(C.menuKey('1', 2), { mode: 0 });
23  assert.deepEqual(C.menuKey('2', 2), { mode: 1 });
24  assert.equal(C.menuKey('3', 2), null, 'no third mode');
25  assert.equal(C.menuKey('0', 2), null);
26  assert.deepEqual(C.menuKey('D', 2), { toggle: true });
27  assert.equal(C.menuKey('d', 2), null, 'd is the split layout');
28  assert.equal(C.menuKey('Enter', 2), null, 'the browser submits: the focus is on the button');
29  assert.equal(C.menuKey('M', 2), null);
30});
31
32test('step: stays in the list', () => {
33  assert.equal(C.step(0, 1, 3), 1);
34  assert.equal(C.step(2, 1, 3), 2);
35  assert.equal(C.step(0, -1, 3), 0);
36  assert.equal(C.step(-1, 1, 3), 0);
37  assert.equal(C.step(-1, -1, 3), 2);
38  assert.equal(C.step(0, 1, 0), -1);
39});
40
41test('rangeURL requires the same file, side and MR and sorts endpoints', () => {
42 const base = 'https://forge.test/demo/merge-requests/1';
43 const a = base+'?on=new:24:dir%2Fa.go#f1';
44 const b = base+'?from=25&on=new:10:dir%2Fa.go#f1';
45 const u = new URL(C.rangeURL(a,b,base));
46 assert.equal(u.searchParams.get('on'),'new:10-24:dir/a.go');
47 assert.equal(u.searchParams.get('from'),'25');
48 assert.equal(u.hash,'#f1');
49 assert.equal(C.rangeURL(a,a,base),null);
50 assert.equal(C.rangeURL(a,b.replace('new:','old:'),base),null);
51 assert.equal(C.rangeURL(a,b.replace('a.go','b.go'),base),null);
52 assert.equal(C.rangeURL(a,b.replace('/1?','/2?'),base),null);
53});
54
55test('quote opens the thread reply and prefills and focuses its textarea', () => {
56 let focused=false;
57 const field={value:'',focus(){focused=true;}};
58 const details={open:false,querySelector(){return field;}};
59 const thread={querySelector(){return details;}};
60 const button={dataset:{quote:'> source\n\n^2 '},closest(){return {closest(){return thread;}};}};
61 assert.equal(C.quote(button),true);
62 assert.equal(details.open,true);
63 assert.equal(field.value,'> source\n\n^2 ');
64 assert.equal(focused,true);
65});
66
67for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the split note (${JSON.stringify(dataset)}), including after lazy loading`, async () => {
68 const saved = globalThis.document;
69 const savedRAF = globalThis.requestAnimationFrame;
70 let scrolled = '';
71 const details = {open:false};
72 const node = id => ({id, querySelector(){return null;}, closest(){return details;}, scrollIntoView(){scrolled=id;}});
73 const nodes = new Map([['c12',node('c12')],['c12-s',node('c12-s')]]);
74 const ph = {dataset:{notes:'c12'}};
75 globalThis.document = {
76   querySelector: sel => sel === '#diffs' ? {dataset} : null,
77   querySelectorAll: () => [ph],
78   getElementById: id => nodes.get(id),
79 };
80 globalThis.requestAnimationFrame = fn => fn();
81 try {
82   assert.equal(window.ermiteMR.reveal('c12'),false);
83   assert.equal(scrolled,'c12-s');
84   assert.equal(details.open,true);
85   nodes.clear(); scrolled=''; details.open=false;
86   assert.equal(window.ermiteMR.reveal('c12'),true);
87   nodes.set('c12',node('c12')); nodes.set('c12-s',node('c12-s'));
88   await Promise.resolve();
89   assert.equal(scrolled,'c12-s');
90   assert.equal(details.open,true);
91 } finally {
92   globalThis.document=saved;
93   globalThis.requestAnimationFrame=savedRAF;
94 }
95});
96
97function footerHarness(extra = {}) {
98  const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]]));
99  const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,
100    setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; },
101    addEventListener(k, v) { this.events[k] = v; }, focus() { this.focused = true; },
102    scrollIntoView() { this.scrolled = (this.scrolled ?? 0) + 1; } });
103  const tabs = [node('mr-tab-commits'), node('mr-tab-checks')];
104  const panels = [node('mr-commits'), node('mr-checks-panel')];
105  const footer = node('footer'); footer.querySelectorAll = () => panels;
106  const nav = node('tabs'); nav.querySelectorAll = () => tabs;
107  const listeners = {}, replaced = [];
108  globalThis.document = { querySelector: (s) => s === '.mr-footer' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? extra.summary ?? null : null,
109    querySelectorAll: (s) => s === '[data-mr-tabs] button' ? tabs : [], addEventListener(k, v) { listeners[k] = v; } };
110  globalThis.location = { hash: '', pathname: '/demo/merge-requests/1', search: '?x=1' };
111  globalThis.addEventListener = (k, v) => { listeners[k] = v; };
112  globalThis.history = { replaceState: (...args) => replaced.push(args) };
113  const restore = () => { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } };
114  const state = () => panels.map((p) => !p.hidden);
115  return { tabs, panels, footer, listeners, replaced, restore, state };
116}
117
118test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
119  const h = footerHarness();
120  try {
121    window.ermiteMR.panels();
122    assert.deepEqual(h.state(), [false, false], 'initially closed');
123    assert.equal(h.tabs[0].attrs['aria-expanded'], 'false');
124    assert.deepEqual(h.replaced, [], 'initial state leaves the URL alone');
125    h.tabs[1].events.click({ preventDefault() {} });
126    assert.deepEqual(h.state(), [false, true]);
127    assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');
128    assert.deepEqual(h.replaced, [[null, '', '#mr-checks-panel']]);
129    location.hash = '#mr-checks-panel';
130    h.tabs[1].events.click({ preventDefault() {} });
131    assert.deepEqual(h.state(), [false, false], 'a second click closes');
132    assert.deepEqual(h.replaced.at(-1), [null, '', '/demo/merge-requests/1?x=1'], 'closing clears the fragment');
133    location.hash = '';
134    h.tabs[1].events.click({ preventDefault() {} });
135    h.tabs[0].events.click({ preventDefault() {} });
136    assert.deepEqual(h.state(), [true, false], 'the other tab switches');
137    h.tabs[0].events.keydown({ key: 'ArrowRight', preventDefault() {} });
138    assert.equal(h.tabs[1].focused, true, 'arrows move focus only');
139    assert.deepEqual(h.state(), [true, false]);
140    location.hash = '#mr-checks'; h.replaced.length = 0; h.listeners.hashchange();
141    assert.deepEqual(h.state(), [false, true], 'hash opens checks');
142    assert.deepEqual(h.replaced, [], 'hash navigation does not rewrite the URL');
143    location.hash = '#mr-commits'; h.listeners.hashchange();
144    assert.deepEqual(h.state(), [true, false]);
145    assert.equal(C.tabIndex('Home', 1, 2), 0);
146    assert.equal(C.tabIndex('End', 0, 2), 1);
147    assert.equal(C.tabIndex('ArrowLeft', 0, 2), 1);
148    assert.equal(C.tabIndex('Escape', 0, 2), null);
149  } finally { h.restore(); }
150});
151
152test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {
153  const text0 = { v: '' }; let writes = 0;
154  const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };
155  const bar = {};
156  const summary = { title: '1/2 failed', textContent: ' 1/2 ', closest: (s) => s === '.mr-bar' ? bar : null };
157  const extra = { summary };
158  const h = footerHarness(extra);
159  let observed = null, callback = null;
160  globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };
161  const nav = document.querySelector('[data-mr-tabs]');
162  nav.querySelector = (s) => s === '[data-checks-label]' ? label : null;
163  try {
164    window.ermiteMR.panels();
165    assert.equal(label.textContent, '1/2 failed', 'initial label sync');
166    assert.equal(observed[0], bar);
167    assert.deepEqual(observed[1], { childList: true, subtree: true }, 'the stable bar observes action and badge replacements');
168    // Replace #mr-actions, including its badge, then replace the badge alone.
169    extra.summary = { title: 'checks running', textContent: '' }; callback();
170    assert.equal(label.textContent, 'checks running');
171    extra.summary = { title: 'checks passed', textContent: '' }; callback();
172    assert.equal(label.textContent, 'checks passed');
173    extra.summary = summary; callback();
174    const before = writes; callback(); callback();
175    assert.equal(writes, before, 'an unchanged label is not written');
176    summary.title = ''; callback();
177    assert.equal(label.textContent, '1/2');
178    summary.textContent = ' '; callback();
179    assert.equal(label.textContent, 'no checks');
180    const click = { target: { closest: (s) => s === '#mr-ck' ? summary : null } };
181    h.listeners.click(click);
182    assert.deepEqual(h.state(), [false, true]);
183    h.listeners.click(click);
184    assert.deepEqual(h.state(), [false, true], 'a second badge click keeps it open');
185  } finally { h.restore(); }
186});
187
188test('footer: panels(index) restores the open panel without touching the URL', () => {
189  const h = footerHarness();
190  try {
191    location.hash = '#mr-commits';
192    window.ermiteMR.panels(1);
193    assert.deepEqual(h.state(), [false, true], 'a restore ignores the fragment');
194    assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');
195    assert.deepEqual(h.replaced, []);
196    assert.equal(h.panels[0].scrolled, undefined, 'a restore does not scroll');
197    assert.equal(h.panels[1].scrolled, undefined);
198    location.hash = '';
199    window.ermiteMR.panels(-1);
200    assert.deepEqual(h.state(), [false, false]);
201  } finally { h.restore(); }
202});
203
204test('footer: scripts take over the panels; a fragment opens and scrolls to its panel', () => {
205  const h = footerHarness();
206  try {
207    location.hash = '#mr-checks';
208    window.ermiteMR.panels();
209    assert.equal(h.footer.attrs['data-panels'], '', 'the CSS fallback of the fragment stops');
210    assert.deepEqual(h.state(), [false, true]);
211    assert.equal(h.panels[1].scrolled, 1, 'the panel of the fragment is brought in');
212    location.hash = '#mr-commits'; h.listeners.hashchange();
213    assert.deepEqual(h.state(), [true, false]);
214    assert.equal(h.panels[0].scrolled, 1);
215  } finally { h.restore(); }
216});
217
218test('touch range: first tap anchors, second tap opens the sorted range', () => {
219  const base = 'https://forge.test/demo/merge-requests/1';
220  const first = base + '?on=new:24:a.go#f1';
221  const last = base + '?on=new:10:a.go#f1';
222  assert.deepEqual(C.touchRange(null, first, base), { start: first, url: null });
223  const result = C.touchRange(first, last, base);
224  assert.equal(result.start, null);
225  assert.equal(new URL(result.url).searchParams.get('on'), 'new:10-24:a.go');
226  assert.deepEqual(C.touchRange(first, first, base), { start: null, url: first });
227});
228
229test('touch range: a different file, side or request becomes a new anchor', () => {
230  const base = 'https://forge.test/demo/merge-requests/1';
231  const first = base + '?on=new:24:a.go#f1';
232  for (const next of [first.replace('new:', 'old:'), first.replace('a.go', 'b.go'), first.replace('/1?', '/2?')]) {
233    assert.deepEqual(C.touchRange(first, next, base), { start: next, url: null });
234  }
235});
236
237test('mobile unified fallback only redirects single-layout split pages and preserves query/hash', () => {
238  const href = 'https://forge.test/demo/merge-requests/1?view=split&from=25&on=new:3:a.go#f26';
239  assert.equal(C.mobileURL(href, 'split', false), null);
240  assert.equal(C.mobileURL(href, undefined, true), null, 'both layouts are switched by CSS');
241  assert.equal(C.mobileURL(href, 'unified', true), null, 'no redirect loop');
242  const url = new URL(C.mobileURL(href, 'split', true));
243  assert.equal(url.searchParams.get('view'), 'unified');
244  assert.equal(url.searchParams.get('from'), '25');
245  assert.equal(url.searchParams.get('on'), 'new:3:a.go');
246  assert.equal(url.hash, '#f26');
247});
248
249test('touch control: lazy links enable toolbar; ordinary taps pass through; ranges navigate and cancel', async () => {
250  const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'localStorage', 'sessionStorage', 'fetch', 'matchMedia'].map((k) => [k, globalThis[k]]));
251  const make = () => ({
252    attrs: {}, events: {}, children: [], textContent: '',
253    setAttribute(k, v) { this.attrs[k] = v; },
254    removeAttribute(k) { delete this.attrs[k]; },
255    addEventListener(k, fn) { this.events[k] = fn; },
256    append(...nodes) { this.children.push(...nodes); },
257    prepend(node) { this.children.unshift(node); },
258    querySelector() { return null; }, querySelectorAll() { return []; },
259  });
260  const diffs = make();
261  diffs.dataset = { diff: 'split' };
262  const base = 'https://forge.test/demo/merge-requests/1';
263  const line = (n) => Object.assign(make(), { href: `${base}?on=new:${n}:a.go#f1`, textContent: String(n) });
264  const a = line(20), b = line(10);
265  let loaded = false;
266  diffs.querySelector = () => loaded ? a : null;
267  let assigned = null;
268  globalThis.document = {
269    querySelector: (sel) => sel === '#diffs[data-mr]' || sel === '#diffs' ? diffs : null,
270    querySelectorAll: () => [], createElement: (tag) => tag === 'template' ? { content: { querySelectorAll: () => [] } } : make(),
271  };
272  globalThis.location = { href: base, pathname: '/demo/merge-requests/1', hash: '', assign(url) { assigned = url; } };
273  globalThis.addEventListener = () => {};
274  const changes = {};
275  const medias = {};
276  globalThis.matchMedia = (q) => medias[q] ??= { matches: true, addEventListener(type, fn) { changes[q] = fn; } };
277  const media = globalThis.matchMedia('(max-width: 720px)');
278  const touchQuery = '(pointer: coarse), (hover: none)';
279  const resize = () => changes['(max-width: 720px)']();
280  globalThis.fetch = async () => { loaded = true; return { ok: true, status: 200, text: async () => '' }; };
281  globalThis.localStorage = globalThis.sessionStorage = { getItem: () => null };
282  const click = (link, modifiers = {}) => {
283    const e = { target: { closest: () => link }, prevented: false, stopped: false,
284      preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; }, ...modifiers };
285    diffs.events.click(e);
286    return e;
287  };
288  try {
289    window.ermiteMR.start();
290    const bar = diffs.children[0];
291    const [button, status] = bar.children;
292    assert.equal(bar.hidden, true, 'no commentable links in the initial window');
293    assert.equal(typeof diffs.events.click, 'function', 'delegation installed before lazy load');
294    await window.ermiteMR.load({ dataset: { src: '/files?from=25' } });
295    assert.equal(bar.hidden, false, 'lazy window refresh reveals range control');
296    assert.equal(diffs.dataset.mobile, 'true');
297    assert.equal(diffs.dataset.diff, 'split', 'preferred layout is retained');
298    media.matches = false;
299    resize();
300    assert.equal(diffs.dataset.mobile, 'false', 'desktop menu returns to preferred layout');
301    assert.equal(diffs.dataset.diff, 'split');
302    assert.equal(click(a).prevented, false, 'normal single-line navigation is untouched');
303    button.events.click();
304    assert.equal(button.attrs['aria-pressed'], 'true');
305    assert.equal(click(a, { shiftKey: true }).prevented, false, 'desktop range selection is untouched');
306    const event = click(a);
307    assert.equal(event.prevented, true);
308    assert.equal(event.stopped, true);
309    assert.ok('data-range-start' in a.attrs);
310    assert.match(status.textContent, /20/);
311    click(b);
312    assert.equal(new URL(assigned).searchParams.get('on'), 'new:10-20:a.go');
313    assert.equal('data-range-start' in a.attrs, false);
314    click(a);
315    button.events.click();
316    assert.equal(button.attrs['aria-pressed'], 'false');
317    assert.equal(status.textContent, '');
318    assert.equal('data-range-start' in a.attrs, false);
319    assert.equal(click(b).prevented, false);
320    // The touch media query stops matching: range mode is switched off.
321    button.events.click();
322    click(a);
323    assert.equal(button.attrs['aria-pressed'], 'true');
324    assert.ok('data-range-start' in a.attrs);
325    medias[touchQuery].matches = false;
326    changes[touchQuery]();
327    assert.equal(button.attrs['aria-pressed'], 'false');
328    assert.equal(status.textContent, '');
329    assert.equal('data-range-start' in a.attrs, false);
330    assert.equal(click(b).prevented, false, 'clicks pass through once touch stops matching');
331  } finally {
332    for (const [k, v] of Object.entries(saved)) {
333      if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
334    }
335  }
336});
337
338// Deterministic timers: no sleeps, and each test restores every mocked global.
339function clock() {
340  const saved = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
341  const tasks = new Map(); let n = 0, now = 0;
342  globalThis.setTimeout = (fn, delay = 0) => { tasks.set(++n, { fn, at: now + delay }); return n; };
343  globalThis.clearTimeout = (id) => tasks.delete(id);
344  return { async tick(ms = 300) { now += ms; const batch = [...tasks].filter(([, t]) => t.at <= now); for (const [id] of batch) tasks.delete(id); for (const [, t] of batch) await t.fn(); },
345    size: () => tasks.size, restore() { Object.assign(globalThis, saved); } };
346}
347
348test('head refresh defers a real textarea draft, resumes after emptying and blurring', async () => {
349  const saved = globalThis.document, c = clock();
350  const text = { value: 'unsent', defaultValue: '' };
351  let fetched = 0, applied = 0; const notices = [];
352  globalThis.document = { activeElement: text, querySelectorAll: () => [text] };
353  const r = window.ermiteMR.refresher({ blocked: window.ermiteMR.hasDraft,
354    fetchPage: async () => { fetched++; return 'page'; }, apply: () => applied++,
355    notice: (v) => notices.push(v), failed: () => assert.fail('unexpected fallback') });
356  try {
357    r.request(); await c.tick();
358    assert.equal(fetched, 0); assert.deepEqual(notices, [true]);
359    text.value = ''; r.resume(); await c.tick();
360    assert.equal(fetched, 0, 'focus still protects an empty draft');
361    document.activeElement = null; r.resume(); await c.tick();
362    assert.equal(fetched, 1); assert.equal(applied, 1); assert.equal(r.pending(), false);
363  } finally { globalThis.document = saved; c.restore(); }
364});
365
366test('head refresh debounces bursts, serializes requests, and reruns after an in-flight push', async () => {
367  const c = clock(); let fetched = 0, applied = 0, resolve;
368  const r = window.ermiteMR.refresher({ blocked: () => false,
369    fetchPage: () => { fetched++; return new Promise((r) => { resolve = r; }); },
370    apply: () => applied++, notice() {}, failed: () => assert.fail('unexpected failure') });
371  try {
372    r.request(); r.request(); r.request(); assert.equal(c.size(), 1);
373    await c.tick(299); assert.equal(fetched, 0);
374    await c.tick(0); assert.equal(fetched, 0);
375    const first = c.tick(1); assert.equal(fetched, 1);
376    r.request(); await c.tick(); assert.equal(fetched, 1, 'one fetch in flight');
377    resolve('one'); await first; assert.equal(applied, 1); assert.equal(c.size(), 1);
378    const second = c.tick(); assert.equal(fetched, 2); resolve('two'); await second;
379    assert.equal(applied, 2); assert.equal(c.size(), 0);
380  } finally { c.restore(); }
381});
382
383test('typing during fetch defers even manual refresh until the draft ends', { timeout: 2000 }, async () => {
384  const c = clock(); let blocked = false, applied = 0, resolve, fetched = 0; const notices = [];
385  const r = window.ermiteMR.refresher({ blocked: () => blocked,
386    fetchPage: () => { fetched++; return new Promise((r) => { resolve = r; }); }, apply: () => { applied++; },
387    notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') });
388  try {
389    r.request(); const first = c.tick(); blocked = true; resolve('page'); await first;
390    assert.equal(applied, 0); assert.deepEqual(notices, [true]);
391    r.request(true); const manual = c.tick();
392    assert.equal(fetched, 1, 'manual refresh must not start a fetch during a draft');
393    await manual; assert.equal(applied, 0);
394    blocked = false; r.resume(); const second = c.tick(); resolve('new page'); await second;
395    assert.equal(applied, 1); assert.equal(notices.at(-1), false);
396  } finally { c.restore(); }
397});
398
399test('fetch failures and non-MR responses take the fallback, never apply', async () => {
400  const saved = { fetch: globalThis.fetch, location: globalThis.location, DOMParser: globalThis.DOMParser };
401  const c = clock(); let fallback = 0;
402  globalThis.location = { href: 'https://forge.test/demo/merge-requests/1?view=split&from=25' };
403  const r = window.ermiteMR.refresher({ blocked: () => false, fetchPage: window.ermiteMR.fetchPage,
404    apply: () => assert.fail('must not swap'), notice() {}, failed: () => fallback++ });
405  try {
406    for (const result of ['network', { ok: false }, { ok: true, redirected: true }, { ok: true, text: async () => '<html>login</html>' }]) {
407      globalThis.fetch = async (url, opts) => {
408        assert.equal(url, location.href); assert.equal(opts.credentials, 'same-origin');
409        if (result === 'network') throw new Error('offline'); return result;
410      };
411      globalThis.DOMParser = class { parseFromString() { return { querySelector: () => null }; } };
412      r.request(); await c.tick();
413    }
414    assert.equal(fallback, 4);
415  } finally {
416    for (const [k,v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; }
417    c.restore();
418  }
419});
420
421test('viewport probe finds a stable path/side/line and restores its offset without line scans', () => {
422  const names = ['document', 'location', 'innerHeight', 'innerWidth', 'scrollY', 'scrollBy', 'scrollTo', 'CSS', 'matchMedia'];
423  const saved = Object.fromEntries(names.map((k) => [k, globalThis[k]]));
424  const rect = (top, bottom = top + 20) => ({ top, bottom, left: 0, width: 900 });
425  let lineTop = 160, headerTop = -200, found = true; const calls = [];
426  const header = { getBoundingClientRect: () => rect(headerTop) };
427  const row = { getBoundingClientRect: () => rect(lineTop), querySelector: () => link };
428  const link = { href: 'https://forge.test/demo/merge-requests/1?on=new:42:a.go#f2', closest: () => row, getAttribute: () => link.href };
429  const file = { getBoundingClientRect: () => rect(headerTop), id: 'f2', dataset: { file: 'a.go' }, querySelector: (s) => s.includes('summary') ? header : s.includes('href$') ? (assert.ok(s.includes('on=new:42:a.go#f3')), found ? link : null) : null };
430  const hit = { closest: (s) => s === 'tr' ? row : file };
431  const diffs = { dataset: { diff: 'unified' }, getBoundingClientRect: () => rect(120), contains: () => true };
432  globalThis.document = {
433    querySelector: (s) => s === '#diffs' ? diffs : s === '.hdr' ? { getBoundingClientRect: () => rect(0, 100) } : null,
434    querySelectorAll: (s) => { assert.equal(s, '#diffs details.dfile', 'only files, never every line'); return found === 'gone' ? [] : [file]; },
435    elementFromPoint: () => hit, documentElement: { scrollHeight: 10000 },
436  };
437  Object.assign(globalThis, { location: { href: link.href }, innerHeight: 800, innerWidth: 1200, scrollY: 2500,
438    scrollBy: (arg) => calls.push(['by', arg]), scrollTo: (arg) => calls.push(['to', arg]), CSS: { escape: (s) => s }, matchMedia: () => ({ matches: false }) });
439  try {
440    const a = window.ermiteMR.scrollAnchor();
441    assert.equal(a.file, 'a.go'); assert.equal(a.on, 'new:42:a.go'); assert.equal(a.offset, 160);
442    file.id = 'f3'; lineTop = 260; window.ermiteMR.restoreAnchor(a);
443    assert.deepEqual(calls.pop(), ['by', { top: 100, behavior: 'instant' }]);
444    found = false; headerTop = -100; window.ermiteMR.restoreAnchor(a);
445    assert.deepEqual(calls.pop(), ['by', { top: 100, behavior: 'instant' }]);
446    found = 'gone'; window.ermiteMR.restoreAnchor({ ...a, y: 20000 });
447    assert.deepEqual(calls.pop(), ['to', { top: 9200, behavior: 'instant' }]);
448  } finally { for (const [k,v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } }
449});
450
451test('empty edited default and focused cform controls protect drafts', () => {
452  const saved = document;
453  try {
454    globalThis.document = { querySelectorAll: () => [{ value: '', defaultValue: 'existing' }], activeElement: null };
455    assert.equal(window.ermiteMR.hasDraft(), true);
456    document.querySelectorAll = () => [];
457    document.activeElement = { closest: (s) => s === '.cform' ? {} : null };
458    assert.equal(window.ermiteMR.hasDraft(), true);
459  } finally { globalThis.document = saved; }
460});
461
462test('failure during a draft keeps pending and resumes after the draft ends', async () => {
463  const c = clock(); let blocked = false, reject, applied = 0, count = 0;
464  const r = window.ermiteMR.refresher({ blocked: () => blocked,
465    fetchPage: () => ++count === 1 ? new Promise((_, no) => { reject = no; }) : Promise.resolve('page'),
466    apply: () => applied++, notice() {}, failed: () => assert.fail('draft must defer fallback') });
467  try {
468    r.request(); const first = c.tick(); blocked = true; reject(new Error('offline')); await first;
469    assert.equal(r.pending(), true); assert.equal(applied, 0);
470    blocked = false; r.resume(); await c.tick(); assert.equal(applied, 1);
471  } finally { c.restore(); }
472});
473
474test('anchor restoration retains the server encoding of tilde and star paths', () => {
475  const saved = { document, CSS: globalThis.CSS, scrollBy: globalThis.scrollBy };
476  try {
477    globalThis.CSS = { escape: (s) => s };
478    globalThis.scrollBy = () => {};
479    for (const path of ['a~.go', 'a*.go']) {
480      const encoded = path.replace('*', '%2A');
481      const row = { getBoundingClientRect: () => ({ top: 5 }) };
482      const file = { id: 'f9', dataset: { file: path }, querySelector: (s) => {
483        if (s === '.dt.uni') return file;
484        assert.equal(s, 'a[href$="on=new:12:' + encoded + '#f9"]');
485        return { closest: () => row };
486      } };
487      globalThis.document = { querySelectorAll: () => [file], querySelector: () => ({ dataset: { diff: 'unified' } }) };
488      window.ermiteMR.restoreAnchor({ file: path, suffix: 'new:12:' + encoded, on: 'new:12:' + path, offset: 5 });
489    }
490  } finally { Object.assign(globalThis, saved); }
491});
492
493for (const control of [
494  { type: 'radio', checked: true, defaultChecked: false },
495  { type: 'checkbox', checked: false, defaultChecked: true },
496  { type: 'number', name: 'start_line', value: '10', defaultValue: '12' },
497  { tagName: 'SELECT', options: [{ selected: true, defaultSelected: false }] },
498]) test(`changed ${control.name || control.type || control.tagName} remains a draft after blur`, () => {
499  const saved = document;
500  try {
501    globalThis.document = { activeElement: null, querySelectorAll(sel) {
502      assert.equal(sel, ':is(form.cform, .cmt form, form.d-acts) :is(textarea, input, select)');
503      return [control];
504    } };
505    assert.equal(window.ermiteMR.hasDraft(), true);
506  } finally { globalThis.document = saved; }
507});
508
509test('intercepted Cancel resets the entire comment form', () => {
510  const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'matchMedia'].map(k => [k, globalThis[k]]));
511  const c = clock(), handlers = {};
512  const diffs = { dataset: { view: 'split' }, querySelector() { return null; }, querySelectorAll() { return []; } };
513  const radio = { type: 'radio', checked: true, defaultChecked: false };
514  const line = { type: 'number', value: '2', defaultValue: '4' };
515  let reset = 0, prevented = false;
516  const form = { reset() { reset++; radio.checked = radio.defaultChecked; line.value = line.defaultValue; }, contains() { return false; } };
517  const cancel = { closest: () => form };
518  globalThis.document = { activeElement: null,
519    querySelector: s => ['#diffs', '#diffs[data-mr]'].includes(s) ? diffs : null,
520    querySelectorAll: s => s.includes('form.cform') ? [radio, line] : [],
521    addEventListener: (type, fn) => { handlers[type] = fn; } };
522  globalThis.location = { pathname: '/demo/merge-requests/1', hash: '', href: 'https://forge.test/demo/merge-requests/1', replace() {} };
523  globalThis.addEventListener = () => {};
524  globalThis.matchMedia = () => ({ matches: true, addEventListener() {} });
525  try {
526    window.ermiteMR.start();
527    window.ermiteMR.headChanged();
528    handlers.click({ target: { closest: () => cancel }, preventDefault() { prevented = true; } });
529    assert.equal(prevented, true);
530    assert.equal(reset, 1);
531    assert.equal(window.ermiteMR.hasDraft(), false);
532  } finally { Object.assign(globalThis, saved); c.restore(); }
533});
534
535
536test('live refresh preserves the open button panel even when the hash points elsewhere', async () => {
537  const { readFileSync } = await import('node:fs');
538  const src = readFileSync(new URL('../static/js/mr.js', import.meta.url), 'utf8');
539  const start = src.indexOf('  async function refreshPage(page)');
540  const end = src.indexOf('  function refreshNotice', start);
541  const { el, installDom } = await import('./dom.mjs');
542  const saved = globalThis.document;
543  const h = footerHarness();
544  try {
545    window.ermiteMR.panels();
546    h.tabs[1].events.click({ preventDefault() {} });
547    location.hash = '#f99';
548    const doc = installDom();
549    Object.defineProperty(doc, 'activeElement', { get: () => null });
550    const diffs = el('div');
551    diffs.dataset.diff = 'unified';
552    const fresh = el('footer', {}, el('section', { attrs: { 'data-mr-panel': '' } }), el('section', { attrs: { 'data-mr-panel': '' } }));
553    const page = { body: { dataset: {} } };
554    const $ = (s, root) => s === '#diffs' ? diffs : s === '.mr-footer' ? (root === page ? fresh : h.footer) : el('div');
555    const $$ = (s, root) => s === '[data-mr-tabs] button' ? h.tabs : s === '[data-mr-panel]' ? fresh.children : [];
556    let selected;
557    const lazy = { reset() {}, watch() {} };
558    const refresh = new Function('$', '$$', 'scrollAnchor', 'hasDraft', 'replaceRegion', 'panels', 'watchBar', 'foldFiles', 'touchComments', 'restoreAnchor', 'window', 'document',
559      'let revealGen = 0; ' + src.slice(start, end) + '; return refreshPage;')(
560      $, $$, () => ({}), () => false, async () => {}, (i) => { selected = i; }, () => {}, () => {}, () => {}, () => {}, { ermiteLazy: lazy }, doc);
561    await refresh(page);
562    assert.equal(selected, 1);
563    assert.deepEqual(fresh.children.map((p) => p.hidden), [true, false]);
564  } finally { h.restore(); globalThis.document = saved; }
565});

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 4

Tags 45

↑↓ moveenter openesc close