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