diff --git a/internal/check-retry-fetch.mjs b/internal/check-retry-fetch.mjs new file mode 100644 index 000000000..b89d8d52c --- /dev/null +++ b/internal/check-retry-fetch.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * check-retry-fetch — a runtime data fetch survives a transport blip, and a real error still fails. + * + * THE DEFECT (Driver, 2026-09-28): net::ERR_NETWORK_CHANGED killed an embedded UPG-100's load. + * + * ★ THE URLs IN HIS CONSOLE CANNOT BE FIXED, BECAUSE THEY DO NOT EXIST IN PRODUCTION. They were module + * URLs — locale JSON with ?import, useOptionalT.js, useShadowStyles.js, useMapTheme.js. The build is + * IIFE with inlineDynamicImports (vite.config.js:216-220), so every one of those is inside the single + * bundle and is never fetched separately. Those failures are dev-server module requests. Wrapping them + * in a retry is impossible AND unnecessary; saying so is the useful part of this card. + * + * ★ THE FAILURE CLASS IS REAL ONE LAYER DOWN, and that is what this guards. A built bundle still makes + * runtime requests — boundaries geojson, the people-group list, prayer statistics — and none of them + * retried. The same blip takes out whichever was in flight, and the feature is simply ABSENT: an empty + * map, a missing outline, a statistic that never arrives, with nothing a reader could act on. + * + * Exit 0 pass · 1 fail. + */ +import { readFileSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const { retryFetch } = await import(resolve(ROOT, 'library/composables/retryFetch.js')) +const fails = [] +const ok = (w, c, d = '') => { if (!c) fails.push(`${w}${d ? `\n ${d}` : ''}`) } +const realFetch = globalThis.fetch + +// ── behaviour ──────────────────────────────────────────────────────────────────────────────────── +{ + let n = 0 + globalThis.fetch = async () => { if (++n < 3) throw new TypeError('Failed to fetch'); return { ok: true, attempt: n } } + const r = await retryFetch('u', undefined, { backoff: () => 1 }) + ok('a transport blip is survived', r.attempt === 3, `resolved on attempt ${r.attempt}`) +} +{ + // ★ COUNT THE ATTEMPTS, do not merely assert that it throws. An unbounded loop with a short backoff + // ALSO throws eventually, so "it failed in the end" passes a retry policy that hammers a dead server + // forever — caught by this gate's own negative test, which is the only reason the weak version did + // not ship. The property is BOUNDED give-up, and the bound is the number. + let calls = 0 + globalThis.fetch = async () => { calls++; throw new TypeError('Failed to fetch') } + let threw = false + try { await retryFetch('u', undefined, { backoff: () => 1 }) } catch { threw = true } + ok('a persistently dead endpoint still FAILS — retrying forever would hide real breakage', threw) + ok('...after a BOUNDED number of attempts, not indefinitely', calls === 3, `fetch called ${calls}x, expected 3`) + calls = 0 + try { await retryFetch('u', undefined, { attempts: 5, backoff: () => 1 }) } catch { /* expected */ } + ok('...and the bound is the caller\'s, when they state one', calls === 5, `fetch called ${calls}x, expected 5`) +} +{ + // A 404/500 RESOLVES. It must be handed back untouched, not retried: the server said something. + let calls = 0 + globalThis.fetch = async () => { calls++; return { ok: false, status: 404 } } + const r = await retryFetch('u', undefined, { backoff: () => 1 }) + ok('a 404 is returned, not retried', r.status === 404 && calls === 1, `fetch called ${calls}x`) +} +{ + // An abort is a DECISION, never a blip. + let calls = 0 + globalThis.fetch = async () => { calls++; const e = new Error('aborted'); e.name = 'AbortError'; throw e } + let threw = false + try { await retryFetch('u', undefined, { backoff: () => 1 }) } catch (e) { threw = e.name === 'AbortError' } + ok('an abort is not retried', threw && calls === 1, `fetch called ${calls}x`) +} +globalThis.fetch = realFetch + +// ── and the shared data paths actually use it ──────────────────────────────────────────────────── +// ★ ONE SITE IS DELIBERATELY EXEMPT AND IS NAMED, not silently skipped. usePosterPrints probes for an +// OPTIONAL pre-rendered .webp and falls through to generating the poster when it is absent — a miss is +// the expected path, so retrying would delay the fallback for an asset that is legitimately not there. +// Everything else in that file, and every other runtime request, must retry. +const EXEMPT = new Map([['library/composables/usePosterPrints.js', 1]]) +for (const rel of [ + 'library/composables/useMapLayers.js', + 'library/composables/useCountryOutline.js', + 'library/lists/peopleGroupList.js', + 'library/composables/usePrayerStatistics.js', + 'library/composables/usePosterPrints.js', + 'library/composables/useSharedMapToken.js', + 'library/composables/useCsvExport.js', + 'library/components/PeopleGroupDetail.vue', +]) { + const src = readFileSync(join(ROOT, rel), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') + const bare = (src.match(/(? allowed) { + fails.push(`${rel} has ${bare} bare fetch() call(s) for a runtime request, ${allowed} allowed — a transport\n` + + ' blip there leaves the feature silently absent. Use retryFetch().') + } +} + +if (fails.length) { + console.error('RETRY-FETCH: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log('RETRY-FETCH: PASS — a blip is survived, a dead endpoint still fails, a 404 and an abort are not ' + + 'retried, and the shared data paths all go through it.') diff --git a/library/components/PeopleGroupDetail.vue b/library/components/PeopleGroupDetail.vue index 4a8fcc829..2279e605a 100644 --- a/library/components/PeopleGroupDetail.vue +++ b/library/components/PeopleGroupDetail.vue @@ -108,6 +108,7 @@