From d6422ed17df0db8fa159a3b9b47524820a068b1b Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Mon, 14 Sep 2026 15:24:39 +0200 Subject: [PATCH 1/2] test(#1375): replace stale call-count assertion with a real contract test The old test asserted "exactly one api('/scope-stats' call exists", which was true when written but broke when a second, legitimate call site (Foreign Traffic tab, analytics.js ~6148) was added that deliberately reuses the Scopes tab's cached response rather than duplicating the request. A source-text occurrence count can't tell a real duplicate fetch apart from two call sites correctly sharing one cache entry. Replaces it with two independent checks: a structural check with no call-count ceiling (every /scope-stats call site uses the correct relative form, however many exist), and a behavioral check that drives both real production entry points (registerPage('analytics').init and window._analyticsRenderForeignTrafficTab) through six caching/dedup/ retry scenarios against the real, unmodified api() helper, using production analytics.js/app.js loaded via vm rather than copying their logic into the test. Co-Authored-By: Claude Sonnet 5 --- test-issue-1375-scope-stats-fetch.js | 333 +++++++++++++++++++++++++-- 1 file changed, 318 insertions(+), 15 deletions(-) diff --git a/test-issue-1375-scope-stats-fetch.js b/test-issue-1375-scope-stats-fetch.js index a68b46a4b..52f5081c6 100644 --- a/test-issue-1375-scope-stats-fetch.js +++ b/test-issue-1375-scope-stats-fetch.js @@ -12,39 +12,342 @@ * * Originally landed on the PR #915 branch (commit 2fd22cee) but that * branch never merged, so the bug resurfaced in subsequent rebases. + * + * ── Contract, revised ───────────────────────────────────────────────── + * This file previously also asserted "exactly one `api('/scope-stats'` + * call exists" — true when this test was written (2026-05-25, commit + * bfebf200, the Scopes tab's own loader at analytics.js ~5140), but a + * second, independent and fully legitimate call site was added ~2 months + * later (2026-07-23, commit 6c9de1254, "feat: flag bridge repeaters on + * the Foreign Traffic unscoped-relay table", analytics.js ~6148) that + * *deliberately* reuses the Scopes tab's cached response (see that call + * site's own doc comment) rather than duplicating the request. "Exactly + * one call site" was never the actual regression being guarded against — + * it was an accidental byproduct of the file's state at the time — and a + * cardinality count of source-text occurrences can't distinguish a real + * duplicate fetch from two call sites correctly sharing one cache entry + * in the first place. + * + * The real contract has two independent halves, both asserted below: + * 1. STRUCTURAL (Part 2): every api() call site that references + * /scope-stats — however many there are — uses the correct, + * non-doubled relative form. This must hold with NO upper bound on + * call-site count, or the next legitimate feature that needs this + * endpoint breaks the test again for no real reason. + * 2. BEHAVIORAL (Part 3): the two actual call sites, exercised through + * their real, unmodified production entry points (not copies, not + * direct api() calls with borrowed arguments), must not cause more + * real network fetches than the api() helper's own TTL-cache / + * in-flight-dedup contract allows. This is the part a source-text + * count could never prove either way. */ 'use strict'; +const vm = require('vm'); const fs = require('fs'); const path = require('path'); +const assert = require('assert'); let passed = 0, failed = 0; -function assert(cond, msg) { +function check(cond, msg) { if (cond) { passed++; console.log(' ✓ ' + msg); } else { failed++; console.error(' ✗ ' + msg); } } +async function checkAsync(name, fn) { + try { + await fn(); + passed++; + console.log(' ✓ ' + name); + } catch (e) { + failed++; + console.error(' ✗ ' + name + ': ' + e.message); + } +} + +// api()'s real implementation (public/app.js ~199-200) does +// `promise.finally(() => _inflight.delete(path))` and discards the +// derived promise. When `promise` rejects (e.g. scenario F's induced +// 500), that discarded `.finally()` promise rejects too, with nothing +// ever attached to observe it — an "orphaned" rejection. In a real +// browser this surfaces only as a benign `Uncaught (in promise)` +// console warning (nothing reads its value; api()'s actual callers all +// get the *original* `promise`, which both real call sites already +// catch — see analytics.js's own try/catch at ~5147 and `.catch()` at +// ~6148). Node's stricter default (crash the process on any unhandled +// rejection) has no browser equivalent, so it would kill this whole +// test run over a pre-existing, functionally-inert characteristic of +// unmodified production code that has nothing to do with the #1375 +// contract under test. Logged, not silently swallowed, and it does not +// touch pass/fail accounting below. +process.on('unhandledRejection', (err) => { + console.error(' (note) benign unhandled rejection from api()\'s orphaned `promise.finally()` ' + + '(public/app.js ~200, pre-existing, browser-harmless) -- not part of the #1375 contract: ' + + (err && err.message)); +}); const src = fs.readFileSync( path.join(__dirname, 'public', 'analytics.js'), 'utf8'); -console.log('\n=== #1375: Scopes tab scope-stats fetch path ==='); +console.log('\n=== #1375 Part 1: historical doubled-prefix regression guard (unchanged) ==='); // Regression guard: the buggy doubled-prefix form must never reappear. const badRe = /api\(\s*['"]\/api\/scope-stats/g; const badMatches = src.match(badRe) || []; -assert(badMatches.length === 0, +check(badMatches.length === 0, "ZERO `api('/api/scope-stats'` occurrences in analytics.js " + '(regression guard for doubled /api prefix)'); -// Positive: the corrected, helper-relative form is present exactly once. -const goodRe = /api\(\s*['"]\/scope-stats/g; -const goodMatches = src.match(goodRe) || []; -assert(goodMatches.length === 1, - "Exactly one `api('/scope-stats'` call exists (the fixed loader) — " + - 'found ' + goodMatches.length); - -console.log('\n=== Summary ==='); -console.log(' Passed: ' + passed); -console.log(' Failed: ' + failed); -console.log('\n#1375 ' + (failed === 0 ? 'PASS' : 'FAIL')); -process.exit(failed === 0 ? 0 : 1); +console.log('\n=== #1375 Part 2: every /scope-stats call site uses the correct relative form (no call-count assumption) ==='); + +// Broad, non-circular net: capture every api(...) call whose FULL quoted +// path argument mentions "scope-stats" ANYWHERE, not just calls that +// already look correct. If a regressed doubled-prefix call existed, it +// would be caught HERE (it does mention "scope-stats"), not filtered out +// before this check runs — that's what makes this non-circular, unlike +// selecting only already-correct-looking calls and then asserting they +// look correct. +const anyScopeStatsCallRe = /api\(\s*(['"])((?:(?!\1)[\s\S])*?scope-stats(?:(?!\1)[\s\S])*?)\1/g; +const scopeStatsCallPaths = []; +let m; +while ((m = anyScopeStatsCallRe.exec(src))) scopeStatsCallPaths.push(m[2]); + +check(scopeStatsCallPaths.length >= 1, + 'at least one api() call site references /scope-stats (found ' + + scopeStatsCallPaths.length + ' — zero would mean the feature was removed, not fixed)'); + +const wrongForm = scopeStatsCallPaths.filter(p => !p.startsWith('/scope-stats')); +check(wrongForm.length === 0, + 'every /scope-stats call site uses the correct relative form (starts with `/scope-stats`, ' + + 'not a doubled `/api/scope-stats` prefix) — offenders: ' + JSON.stringify(wrongForm)); + +console.log('\n=== #1375 Part 3: behavioral — the two real call sites share the api() cache correctly ==='); +console.log(' (Scopes tab load(w), analytics.js ~5140, and Foreign Traffic tab load(),'); +console.log(' analytics.js ~6148, driven through their real, unmodified production entry'); +console.log(' points — registerPage(\'analytics\',{init}) and the exported'); +console.log(' window._analyticsRenderForeignTrafficTab — not copies, not a direct api()'); +console.log(' call with borrowed Scopes arguments.)'); + +function makeSandbox() { + const fetchLog = []; + let fetchBehavior = (url) => ({ ok: true, status: 200, json: async () => ({}), headers: { get: () => null } }); + + // Auto-vivifying generic mock element: renderScopesTab (analytics.js) + // is a large function that wires many DOM ids while building its + // static frame. Rather than enumerate every one (and risk silently + // missing one as the production function evolves), any id not + // explicitly pre-seeded gets a fresh, memoized, fully no-op element — + // same style as this codebase's existing `getEl`/`makeEl` id-keyed + // mock-element factories (see test-frontend-helpers.js), generalized + // instead of hand-listing ids, since we do not copy renderScopesTab's + // body into this test and so cannot enumerate its DOM touches by hand. + const elements = new Map(); + function elFor(id) { + if (!elements.has(id)) { + elements.set(id, { + id, innerHTML: '', textContent: '', value: '', + style: {}, dataset: {}, classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, + addEventListener() {}, removeEventListener() {}, + // init()'s deep-link tab selector (analytics.js ~205) does + // `analyticsTabs.querySelector('[data-tab="scopes"]')` to find the + // tab button matching a `#/analytics?tab=scopes` hash, then reads + // it back truthy to set `_currentTab`. A generic `[data-tab="x"]` + // match (rather than special-casing the analyticsTabs id) covers + // that real production lookup without hand-modeling the tab bar's + // actual child markup. + querySelector(sel) { + const m = /\[data-tab="([^"]+)"\]/.exec(sel || ''); + return m ? elFor('tab-btn:' + m[1]) : null; + }, + querySelectorAll() { return []; }, + appendChild() {}, closest() { return null; }, + getAttribute() { return null; }, setAttribute() {}, removeAttribute() {}, + }); + } + return elements.get(id); + } + + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => elFor(Symbol()), + head: { appendChild: () => {} }, + getElementById: (id) => elFor(id), + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: (fn) => setTimeout(fn, 0), clearTimeout: () => {}, + setInterval: () => 1, clearInterval: () => {}, + performance: { now: () => Date.now() }, + localStorage: (() => { const s = {}; return { getItem: k => (k in s ? s[k] : null), setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(), + sessionStorage: (() => { const s = {}; return { getItem: k => (k in s ? s[k] : null), setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(), + location: { hash: '' }, + getHashParams: function () { return new URLSearchParams((ctx.location.hash.split('?')[1] || '')); }, + CustomEvent: class CustomEvent {}, + Map, Promise, URLSearchParams, Set, + addEventListener: () => {}, + dispatchEvent: () => {}, + requestAnimationFrame: (cb) => setTimeout(cb, 0), + getComputedStyle: () => ({ getPropertyValue: () => '' }), + registerPage: (name, mod) => { ctx.__registeredPages = ctx.__registeredPages || {}; ctx.__registeredPages[name] = mod; }, + timeAgo: (iso) => (iso ? 'x ago' : '—'), + RegionFilter: { init: () => {}, onChange: () => {}, regionQueryString: () => '' }, + AreaFilter: { init: () => {}, onChange: () => {}, areaQueryString: () => '' }, + initTabBar: () => {}, + onWS: () => {}, offWS: () => {}, connectWS: () => {}, + makeColumnsResizable: () => {}, + IATA_COORDS_GEO: {}, + }; + ctx.fetch = function (url) { + fetchLog.push({ url, t: Date.now() }); + const r = fetchBehavior(url); + return r instanceof Promise ? r : Promise.resolve(r); + }; + vm.createContext(ctx); + + function loadFile(file) { + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } + loadFile('public/roles.js'); + loadFile('public/app.js'); // REAL, unmodified api() — real _apiCache/_inflight. + // app.js declares its OWN top-level `function registerPage(name, mod)` + // (app.js ~1110), which — as a global function declaration evaluated in + // this vm context — overwrites the ctx.registerPage stub set above. + // Re-stub it now, after app.js has loaded and before analytics.js calls + // it, so analytics.js's unqualified `registerPage(...)` reference + // (resolved against the global object at call time, same as in a real + // browser) reaches OUR capture instead of app.js's private `pages` map. + ctx.registerPage = (name, mod) => { ctx.__registeredPages = ctx.__registeredPages || {}; ctx.__registeredPages[name] = mod; }; + ctx.window.MC_GEO_FILTER = { latMin: 53, latMax: 59, lonMin: 6, lonMax: 15 }; + ctx.fetchAllNodes = async () => ({ nodes: [] }); // Foreign Traffic's node list; irrelevant to this contract. + try { loadFile('public/analytics.js'); } catch (e) { for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; } + + return { + ctx, + setFetchBehavior: (fn) => { fetchBehavior = fn; }, + scopeStatsCalls: () => fetchLog.filter(e => e.url.includes('/scope-stats')), + // Real production entry point for the Scopes tab: registerPage's + // captured `init`, exactly as app.js's router invokes it (this + // reaches the real, unexposed renderScopesTab -> load(w)). + driveScopesTab: async function (windowValue) { + if (windowValue) ctx.sessionStorage.setItem('scopes_window', windowValue); + ctx.location.hash = '#/analytics?tab=scopes'; + const appEl = { innerHTML: '' }; + await ctx.__registeredPages.analytics.init(appEl); + }, + // Real, exported production entry point for the Foreign Traffic tab. + driveForeignTrafficTab: function () { + return ctx.window._analyticsRenderForeignTrafficTab(elFor('foreignTrafficContent')); + }, + }; +} + +// Drains already-scheduled microtasks/macrotasks so unawaited +// fire-and-forget chains inside init()/renderScopesTab() (loadAnalytics() +// and load(w) are both called without awaiting their result — matching +// the real production code) get a chance to run before we inspect the +// fetch log. setImmediate yields to the event loop; this is deterministic +// draining of already-queued work, not a real-time sleep or fake timer. +function flush(times) { + let p = Promise.resolve(); + for (let i = 0; i < (times || 25); i++) p = p.then(() => new Promise((r) => setImmediate(r))); + return p; +} + +(async () => { + await checkAsync('A. Scopes(24h) then Foreign Traffic, within TTL: exactly one fetch to /api/scope-stats?window=24h', async () => { + const h = makeSandbox(); + await h.driveScopesTab('24h'); + await flush(); + h.driveForeignTrafficTab(); + await flush(); + const calls = h.scopeStatsCalls(); + assert.strictEqual(calls.length, 1, 'expected exactly 1 fetch, got ' + calls.length + ': ' + JSON.stringify(calls.map(c => c.url))); + assert.strictEqual(calls[0].url, '/api/scope-stats?window=24h'); + }); + + await checkAsync('B. Reverse order — Foreign Traffic then Scopes(24h), within TTL: same expectation', async () => { + const h = makeSandbox(); + h.driveForeignTrafficTab(); + await flush(); + await h.driveScopesTab('24h'); + await flush(); + const calls = h.scopeStatsCalls(); + assert.strictEqual(calls.length, 1, 'expected exactly 1 fetch, got ' + calls.length + ': ' + JSON.stringify(calls.map(c => c.url))); + assert.strictEqual(calls[0].url, '/api/scope-stats?window=24h'); + }); + + await checkAsync('C. Scopes on a different valid window (7d), then Foreign Traffic: exactly two fetches, distinct URLs', async () => { + const h = makeSandbox(); + await h.driveScopesTab('7d'); + await flush(); + h.driveForeignTrafficTab(); + await flush(); + const calls = h.scopeStatsCalls(); + assert.strictEqual(calls.length, 2, 'expected exactly 2 fetches, got ' + calls.length + ': ' + JSON.stringify(calls.map(c => c.url))); + const urls = calls.map(c => c.url).sort(); + assert.deepStrictEqual(urls, ['/api/scope-stats?window=24h', '/api/scope-stats?window=7d']); + }); + + await checkAsync('D. Slow response race: both 24h code paths start before either resolves — one fetch via in-flight dedup', async () => { + const h = makeSandbox(); + let resolveScopeStats; + h.setFetchBehavior((url) => { + if (url.includes('/scope-stats')) { + return new Promise((res) => { resolveScopeStats = () => res({ ok: true, status: 200, json: async () => ({ bridgeRepeaters: [] }), headers: { get: () => null } }); }); + } + return { ok: true, status: 200, json: async () => ({}), headers: { get: () => null } }; + }); + const p1 = h.driveScopesTab('24h'); // fires loadAnalytics() + eventually load('24h'), unawaited internally + const p2 = h.driveForeignTrafficTab(); // fires its own /scope-stats call near-immediately + await flush(5); // let both chains reach their fetch() call before either resolves + const midCalls = h.scopeStatsCalls(); + assert.strictEqual(midCalls.length, 1, 'expected exactly 1 in-flight fetch before resolving, got ' + midCalls.length + ': ' + JSON.stringify(midCalls.map(c => c.url))); + resolveScopeStats(); + await Promise.all([p1, p2]); + await flush(); + const calls = h.scopeStatsCalls(); + assert.strictEqual(calls.length, 1, 'expected exactly 1 total fetch, got ' + calls.length + ': ' + JSON.stringify(calls.map(c => c.url))); + }); + + await checkAsync('E. Expired TTL: Scopes(24h), cache expires, Foreign Traffic legitimately re-fetches', async () => { + const h = makeSandbox(); + await h.driveScopesTab('24h'); + await flush(); + assert.strictEqual(h.scopeStatsCalls().length, 1, 'sanity: first call happened'); + h.ctx.invalidateApiCache('/scope-stats'); // deterministic stand-in for the real 30s TTL elapsing + h.driveForeignTrafficTab(); + await flush(); + const calls = h.scopeStatsCalls(); + assert.strictEqual(calls.length, 2, 'expected a fresh fetch after expiry, got ' + calls.length + ' total: ' + JSON.stringify(calls.map(c => c.url))); + }); + + await checkAsync('F. First response fails: the next call site must still legitimately re-fetch (a failure caches nothing)', async () => { + const h = makeSandbox(); + let scopeStatsAttempts = 0; + h.setFetchBehavior((url) => { + if (!url.includes('/scope-stats')) return { ok: true, status: 200, json: async () => ({}), headers: { get: () => null } }; + scopeStatsAttempts++; + return scopeStatsAttempts === 1 + ? { ok: false, status: 500, json: async () => ({}), headers: { get: () => null } } + : { ok: true, status: 200, json: async () => ({ bridgeRepeaters: [] }), headers: { get: () => null } }; + }); + await h.driveScopesTab('24h'); // load(w) catches its own error internally (see analytics.js ~5148) — does not throw out of init() + await flush(); + h.driveForeignTrafficTab(); + await flush(); + const calls = h.scopeStatsCalls(); + assert.strictEqual(calls.length, 2, 'expected a real retry after the first failure, got ' + calls.length + ': ' + JSON.stringify(calls.map(c => c.url))); + }); + + console.log('\n=== Summary ==='); + console.log(' Passed: ' + passed); + console.log(' Failed: ' + failed); + console.log('\n#1375 ' + (failed === 0 ? 'PASS' : 'FAIL')); + process.exitCode = failed === 0 ? 0 : 1; +})(); From fe1441c6dfa66bcf5773161a05397cd2a221c4fb Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Mon, 14 Sep 2026 18:06:01 +0200 Subject: [PATCH 2/2] reviewfix(test): stop suppressing the real unhandled-rejection crash in #1375 Removes the global `process.on('unhandledRejection', ...)` handler that blanket-logged and swallowed any unhandled rejection during this file's run. This test's Part 3 scenarios load the real, unmodified public/app.js via vm, so they exercise api()'s actual `promise.finally(() => _inflight.delete(path))` line -- when the underlying request rejects (e.g. scenario F's induced 500), the discarded `.finally()` promise rejects too with nothing observing it, which is a genuine unhandled rejection that will crash the process under Node's default behavior. That crash depends entirely on whether PR #53's fix to that line (`.catch(() => {})`) is present in the tree. Replaced the suppression with a short comment documenting this as a real, intentional cross-branch dependency instead of hiding it: running this file against `codex/fix-1375-scope-stats-test` alone (without PR #53) is expected to crash for exactly this reason, and that is correct, not a bug in this test. Also added a minimal completion guarantee mirroring the reviewed pattern already landed in the sibling file test-app-api-inflight-cleanup-rejection.js: `process.exitCode` is set to 1 up front and only flipped to 0 after every scenario A-F is confirmed to have run (tracked via a `ranScenarios` set populated in checkAsync's `finally` block) and all of them passed. No watchdog timer was added -- scenario D's existing pre-resolve `assert.strictEqual(midCalls.length, 1, ...)` already throws synchronously before the one hang-prone `Promise.all()` in this file's scenarios is ever reached, so a broader rewrite wasn't warranted. Verified via a synthetic (non-merge) integration tree combining this change with PR #53's HEAD tree (340615953978fb7eccf62c236e7950530cf3ead3): 10x runs of this file and 3x runs of the sibling file both reached and passed all scenarios (6/6 and 8/8) under `node --unhandled-rejections=strict`, four negative controls confirmed the intended failure modes (PR #53 reverted -> genuine crash with the documented signature; doubled-prefix and cache-sharing regressions -> real structural/behavioral test failures, not crashes; broken dedup -> the completion guarantee prevents a silent clean exit 0), and the integration tree's own CI JS test sequence (from .github/workflows/deploy.yml) reached and passed both this file and the sibling file in their declared order before hitting an unrelated, pre-existing failure later in the sequence (test-issue-1648-m4-emoji-scan.js, unrelated to this change). Co-Authored-By: Claude Sonnet 5 --- test-issue-1375-scope-stats-fetch.js | 52 ++++++++++++++++------------ 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/test-issue-1375-scope-stats-fetch.js b/test-issue-1375-scope-stats-fetch.js index 52f5081c6..51d576f51 100644 --- a/test-issue-1375-scope-stats-fetch.js +++ b/test-issue-1375-scope-stats-fetch.js @@ -48,12 +48,18 @@ const fs = require('fs'); const path = require('path'); const assert = require('assert'); +process.exitCode = 1; // Flipped to 0 only after every scenario (A-F) is confirmed to have run AND all passed. + +const EXPECTED_SCENARIOS = ['A', 'B', 'C', 'D', 'E', 'F']; +const ranScenarios = new Set(); + let passed = 0, failed = 0; function check(cond, msg) { if (cond) { passed++; console.log(' ✓ ' + msg); } else { failed++; console.error(' ✗ ' + msg); } } async function checkAsync(name, fn) { + const label = (/^([A-F])\./.exec(name) || [])[1]; try { await fn(); passed++; @@ -61,29 +67,22 @@ async function checkAsync(name, fn) { } catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); + } finally { + if (label) ranScenarios.add(label); } } -// api()'s real implementation (public/app.js ~199-200) does -// `promise.finally(() => _inflight.delete(path))` and discards the -// derived promise. When `promise` rejects (e.g. scenario F's induced -// 500), that discarded `.finally()` promise rejects too, with nothing -// ever attached to observe it — an "orphaned" rejection. In a real -// browser this surfaces only as a benign `Uncaught (in promise)` -// console warning (nothing reads its value; api()'s actual callers all -// get the *original* `promise`, which both real call sites already -// catch — see analytics.js's own try/catch at ~5147 and `.catch()` at -// ~6148). Node's stricter default (crash the process on any unhandled -// rejection) has no browser equivalent, so it would kill this whole -// test run over a pre-existing, functionally-inert characteristic of -// unmodified production code that has nothing to do with the #1375 -// contract under test. Logged, not silently swallowed, and it does not -// touch pass/fail accounting below. -process.on('unhandledRejection', (err) => { - console.error(' (note) benign unhandled rejection from api()\'s orphaned `promise.finally()` ' + - '(public/app.js ~200, pre-existing, browser-harmless) -- not part of the #1375 contract: ' + - (err && err.message)); -}); +// This file's behavioral scenarios (Part 3, A-F) load the REAL, +// unmodified public/app.js via vm, so they exercise api()'s actual +// `promise.finally(() => _inflight.delete(path))` line. That line +// discards its derived promise; when the underlying request rejects +// (e.g. scenario F's induced 500), the derived promise rejects too +// with nothing observing it -- a genuine unhandled rejection. PR #53 +// fixes this in public/app.js via an added `.catch(() => {})`. Without +// PR #53 present in the tree, running this file against +// `codex/fix-1375-scope-stats-test` alone is expected to CRASH under +// Node's default unhandled-rejection behavior -- a real, documented +// cross-branch dependency, not a bug in this test. No longer suppressed. const src = fs.readFileSync( path.join(__dirname, 'public', 'analytics.js'), 'utf8'); @@ -345,9 +344,18 @@ function flush(times) { assert.strictEqual(calls.length, 2, 'expected a real retry after the first failure, got ' + calls.length + ': ' + JSON.stringify(calls.map(c => c.url))); }); + const missing = EXPECTED_SCENARIOS.filter((l) => !ranScenarios.has(l)); + if (missing.length > 0) { + failed++; + console.error('\n✗ INCOMPLETE SUITE: scenario(s) ' + missing.join(', ') + ' never ran to completion (expected exactly ' + + EXPECTED_SCENARIOS.join(', ') + ')'); + } + console.log('\n=== Summary ==='); + console.log(' Ran: ' + Array.from(ranScenarios).sort().join(', ') + ' (' + ranScenarios.size + '/' + EXPECTED_SCENARIOS.length + ')'); console.log(' Passed: ' + passed); console.log(' Failed: ' + failed); - console.log('\n#1375 ' + (failed === 0 ? 'PASS' : 'FAIL')); - process.exitCode = failed === 0 ? 0 : 1; + const complete = missing.length === 0; + console.log('\n#1375 ' + (failed === 0 && complete ? 'PASS' : 'FAIL')); + process.exitCode = (failed === 0 && complete) ? 0 : 1; })();