A print frame is kept until it has printed: its 60 s clock starts at load, not at creation
openPrintFrame's autoprint prints on the frame's 'load', which waits for every image, but the frame was removed 60 s after it was CREATED, loaded or not. The Wall's poster loads 939 photos (132 MB), so on a connection below about 18 Mbps the frame was gone before 'load': no print dialog, and nothing to say so. Now the frame is reclaimed 60 s after its load, at afterprint (as before), or at a 10-minute ceiling for a load that never ends. printImageBlob revoked its object URL on the same creation clock, which blanks an image that has not loaded; it now follows the frame's clock too. New gate check:print-frame-reclaim (gates:pre), fake timers and a fake frame, so 61 s and 10 minutes take no time: a load at 61 s still finds the frame (and the object URL), 60 s after load it is reclaimed, a frame that never loads goes at the ceiling and not before, afterprint reclaims at once. Two-sided: the copy before this fix fails 6 of its 12 cases (the load-after-61-s and ceiling arms); the controls pass on both.
This commit is contained in:
parent
592880ac75
commit
f30006d02a
3 changed files with 138 additions and 11 deletions
106
internal/check-print-frame-reclaim.mjs
Normal file
106
internal/check-print-frame-reclaim.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-print-frame-reclaim — a print frame is kept until it has printed, and reclaimed after. Headless,
|
||||
* with fake timers and a fake frame, so "61 s" and "10 minutes" take no time at all.
|
||||
*
|
||||
* THE DEFECT THIS GUARDS (2026-09-29). openPrintFrame's autoprint prints on the frame's 'load', which
|
||||
* waits for every image. It also removed the frame 60 s after CREATION, loaded or not. The Wall's
|
||||
* poster loads 939 photos (132 MB), so on a connection below about 18 Mbps the frame was gone before
|
||||
* 'load': no print dialog, and nothing to say so. printImageBlob revoked its object URL on the same
|
||||
* clock, which blanks an image that has not loaded yet.
|
||||
*
|
||||
* ASSERTS, for openPrintFrame's frame AND printImageBlob's object URL:
|
||||
* 1. a frame whose 'load' arrives at 61 s is still there (so it prints), and is reclaimed 60 s later;
|
||||
* 2. a frame that never loads is reclaimed at the ceiling (10 minutes), and not before;
|
||||
* 3. afterprint reclaims at once.
|
||||
* `--impl=<path>` runs the same assertions against another copy of printInPlace.js; the copy before
|
||||
* this fix must FAIL arms 1 and 2 (the two-sided proof).
|
||||
* EXIT 0 pass · 1 fail
|
||||
*/
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const implArg = process.argv.find((a) => a.startsWith('--impl='))
|
||||
const IMPL = implArg ? resolve(implArg.slice(7)) : resolve(ROOT, 'library/composables/printInPlace.js')
|
||||
|
||||
// ── fake timers: a clock that only moves when the test says so ────────────────────────────────────
|
||||
let now = 0, seq = 0
|
||||
const timers = new Map()
|
||||
globalThis.setTimeout = (fn, ms = 0) => { const id = ++seq; timers.set(id, { at: now + Math.max(0, ms), fn }); return id }
|
||||
globalThis.clearTimeout = (id) => { timers.delete(id) }
|
||||
function advanceTo(t) {
|
||||
for (;;) {
|
||||
let next = null
|
||||
for (const [id, x] of timers) if (x.at <= t && (!next || x.at < next[1].at || (x.at === next[1].at && id < next[0]))) next = [id, x]
|
||||
if (!next) break
|
||||
timers.delete(next[0]); now = next[1].at; next[1].fn()
|
||||
}
|
||||
now = t
|
||||
}
|
||||
function reset() { now = 0; timers.clear() }
|
||||
|
||||
// ── a fake frame: records what was written, and whether it is still in the page ───────────────────
|
||||
let frame = null
|
||||
const body = { appendChild: (f) => { f.parentNode = body } }
|
||||
globalThis.document = {
|
||||
body,
|
||||
createElement: () => {
|
||||
const listeners = {}
|
||||
frame = {
|
||||
parentNode: null, style: {}, html: '',
|
||||
setAttribute() {}, remove() { this.parentNode = null },
|
||||
contentDocument: { open() {}, write(s) { frame.html += s }, close() {} },
|
||||
contentWindow: { addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn) } },
|
||||
fire(type) { for (const fn of listeners[type] || []) fn() },
|
||||
}
|
||||
return frame
|
||||
},
|
||||
}
|
||||
const revoked = []
|
||||
globalThis.URL = { createObjectURL: () => 'blob:fake', revokeObjectURL: (u) => revoked.push([u, now]) }
|
||||
const attached = () => !!(frame && frame.parentNode)
|
||||
|
||||
const { openPrintFrame, printImageBlob } = await import(pathToFileURL(IMPL).href)
|
||||
const fails = []
|
||||
const check = (ok, what) => { console.log(` ${ok ? 'ok ' : 'FAIL'} ${what}`); if (!ok) fails.push(what) }
|
||||
|
||||
// ── openPrintFrame ─────────────────────────────────────────────────────────────────────────────────
|
||||
reset(); openPrintFrame('<img src="x.jpg">', { autoprint: true })
|
||||
check(frame.html.includes('window.print()'), 'autoprint writes its print-on-load bootstrap (control)')
|
||||
advanceTo(61000)
|
||||
check(attached(), "frame: a 'load' that arrives at 61 s finds the frame still there, so the poster prints")
|
||||
frame.fire('load'); advanceTo(61000 + 59999)
|
||||
check(attached(), "frame: kept for 60 s after its 'load' (the dialog is up)")
|
||||
advanceTo(61000 + 60000)
|
||||
check(!attached(), "frame: reclaimed 60 s after its 'load'")
|
||||
|
||||
reset(); openPrintFrame('<img src="x.jpg">', { autoprint: true })
|
||||
advanceTo(599999)
|
||||
check(attached(), 'frame: a load that never ends is not reclaimed before the ceiling')
|
||||
advanceTo(600000)
|
||||
check(!attached(), 'frame: a load that never ends is reclaimed at the 10-minute ceiling')
|
||||
|
||||
reset(); openPrintFrame('<img src="x.jpg">', { autoprint: true })
|
||||
advanceTo(1000); frame.fire('load'); advanceTo(2000); frame.fire('afterprint'); advanceTo(2000)
|
||||
check(!attached(), 'frame: afterprint reclaims it at once')
|
||||
|
||||
// ── printImageBlob's object URL ────────────────────────────────────────────────────────────────────
|
||||
reset(); revoked.length = 0; printImageBlob({ size: 1 })
|
||||
advanceTo(61000)
|
||||
check(!revoked.length, "object URL: not revoked before a 'load' that arrives at 61 s (the preview would be blank)")
|
||||
frame.fire('load'); advanceTo(61000 + 60000)
|
||||
check(revoked.length === 1 && revoked[0][1] === 121000, "object URL: revoked 60 s after the frame's 'load'")
|
||||
|
||||
reset(); revoked.length = 0; printImageBlob({ size: 1 })
|
||||
advanceTo(599999)
|
||||
check(!revoked.length, 'object URL: a load that never ends keeps it until the ceiling')
|
||||
advanceTo(600000)
|
||||
check(revoked.length === 1, 'object URL: revoked at the 10-minute ceiling')
|
||||
|
||||
reset(); revoked.length = 0; printImageBlob({ size: 1 })
|
||||
advanceTo(500); frame.fire('load'); frame.fire('afterprint'); advanceTo(500)
|
||||
check(revoked.length >= 1 && revoked[0][1] === 500, 'object URL: afterprint revokes it at once')
|
||||
|
||||
if (fails.length) { console.log(`PRINT-FRAME-RECLAIM: FAIL — ${fails.length} case(s) [${IMPL.replace(ROOT + '/', '')}]`); process.exit(1) }
|
||||
console.log('PRINT-FRAME-RECLAIM: PASS — a print frame waits for its load however long it takes, is reclaimed 60 s after it, at afterprint, or at the 10-minute ceiling; its object URL follows the same clock.')
|
||||
|
|
@ -18,7 +18,8 @@
|
|||
* engine, and a zero-height layout can print a blank first page.
|
||||
* - autoprint appends the print bootstrap AFTER the caller's markup so it runs once the
|
||||
* document is parsed (matches <img onload> timing).
|
||||
* - afterprint + a 60s floor reclaim the frame, so repeated prints never leak nodes.
|
||||
* - afterprint, or 60 s after the frame has LOADED, reclaims the frame, so repeated prints never
|
||||
* leak nodes. A 10-minute ceiling reclaims a frame whose load never ends.
|
||||
*
|
||||
* Returns the frame's contentWindow, or null when the host is sandboxed/detached (caller
|
||||
* keeps its own fallback).
|
||||
|
|
@ -39,13 +40,27 @@ export function openPrintFrame(bodyHtml, { autoprint = false } = {}) {
|
|||
}
|
||||
doc.close()
|
||||
|
||||
const drop = () => { if (frame.parentNode) frame.remove() }
|
||||
try { frame.contentWindow.addEventListener('afterprint', () => setTimeout(drop, 0)) } catch { /* cross-doc guard */ }
|
||||
setTimeout(drop, 60000)
|
||||
// ★ THE 60 s CLOCK STARTS AT 'load', NOT AT CREATION (2026-09-29). autoprint prints on 'load', which
|
||||
// waits for every image in the frame. The Wall's poster loads 939 photos (132 MB), so below about
|
||||
// 18 Mbps its 'load' came after 60 s, and the frame had already been removed: no dialog and no
|
||||
// message. afterprint still drops the frame at once; FRAME_CEILING_MS reclaims one that never loads.
|
||||
let ceiling = null
|
||||
const drop = () => { clearTimeout(ceiling); if (frame.parentNode) frame.remove() }
|
||||
ceiling = setTimeout(drop, FRAME_CEILING_MS)
|
||||
try {
|
||||
const win = frame.contentWindow
|
||||
win.addEventListener('afterprint', () => setTimeout(drop, 0))
|
||||
win.addEventListener('load', () => setTimeout(drop, FRAME_KEEP_MS), { once: true })
|
||||
} catch { setTimeout(drop, FRAME_KEEP_MS) /* cross-doc guard: no events to wait for */ }
|
||||
|
||||
return frame.contentWindow
|
||||
}
|
||||
|
||||
/** How long a print frame (and printImageBlob's object URL) is kept after the frame has loaded. */
|
||||
export const FRAME_KEEP_MS = 60000
|
||||
/** The absolute ceiling for a print frame whose load never ends. */
|
||||
export const FRAME_CEILING_MS = 600000
|
||||
|
||||
/**
|
||||
* Print a rendered poster IMAGE, at its real paper size, through the same in-place frame.
|
||||
*
|
||||
|
|
@ -59,9 +74,9 @@ export function openPrintFrame(bodyHtml, { autoprint = false } = {}) {
|
|||
* poster previewed on A4 is a different document from the one that was rendered. margin:0 because the
|
||||
* renderer already composed the margins into the image; a second set would scale the poster down.
|
||||
*
|
||||
* The object URL is revoked on afterprint AND on the frame's 60s reclaim, since afterprint does not
|
||||
* fire in every engine when the dialog is dismissed. Revoking early would blank the preview, so the
|
||||
* image element is kept alive by the frame itself rather than by a timer here.
|
||||
* The object URL is revoked on afterprint AND on the frame's own clock (60 s after it has loaded, or
|
||||
* the ceiling), since afterprint does not fire in every engine when the dialog is dismissed. Revoking
|
||||
* before the image has loaded would blank the preview, which is why that clock starts at 'load'.
|
||||
*
|
||||
* Returns true when the dialog was opened, false when the host blocked the frame — the caller decides
|
||||
* what to do about it, because a silent no-op is exactly the failure this replaced.
|
||||
|
|
@ -80,9 +95,13 @@ export function printImageBlob(blob, { widthIn = 8.5, heightIn = 11, title = '',
|
|||
+ `<body><img src="${url}" alt="${esc(alt || title)}"></body></html>`
|
||||
const win = openPrintFrame(html, { autoprint: true })
|
||||
if (!win) { URL.revokeObjectURL(url); return false }
|
||||
const revoke = () => { try { URL.revokeObjectURL(url) } catch { /* already gone */ } }
|
||||
try { win.addEventListener('afterprint', () => setTimeout(revoke, 0)) } catch { /* cross-doc guard */ }
|
||||
setTimeout(revoke, 60000)
|
||||
let ceiling = null
|
||||
const revoke = () => { clearTimeout(ceiling); try { URL.revokeObjectURL(url) } catch { /* already gone */ } }
|
||||
ceiling = setTimeout(revoke, FRAME_CEILING_MS)
|
||||
try {
|
||||
win.addEventListener('afterprint', () => setTimeout(revoke, 0))
|
||||
win.addEventListener('load', () => setTimeout(revoke, FRAME_KEEP_MS), { once: true })
|
||||
} catch { setTimeout(revoke, FRAME_KEEP_MS) /* cross-doc guard */ }
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@
|
|||
"check:prayer-walk-planner": "node internal/check-prayer-walk-planner.mjs",
|
||||
"check:preset-config-merge": "node internal/check-preset-config-merge.mjs",
|
||||
"check:print-geometry": "node internal/check-print-geometry.mjs",
|
||||
"check:print-frame-reclaim": "node internal/check-print-frame-reclaim.mjs",
|
||||
"check:pin-dim-slider": "node plugin-designs/doxa-research-map/scripts/check-pin-dim-slider.mjs",
|
||||
"check:profile-table": "node internal/check-profile-table.mjs",
|
||||
"check:profiles-mount": "node internal/check-composites-mount.mjs --all-profiles",
|
||||
|
|
@ -325,7 +326,7 @@
|
|||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch check:print-frame-reclaim",
|
||||
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
|
|
@ -522,6 +523,7 @@
|
|||
"check:pin-selection": "the shared source reader understands BOTH engines: mapbox-gl stores a GeoJSON source _data as the FeatureCollection, maplibre-gl wraps it as { geojson } (or { url } when remote). Reading only the Mapbox shape returned an EMPTY list on MapLibre — the keyless engine public builds use — which silently killed zoomToLegendRow camera fits on three maps and the research packet row selection, with no error because every caller no-ops on no features. Asserts both shapes, that a remote source returns [] AND warns, the serialize fallback, and that selectedPinIds agrees with the reader. Headless, no map. Exit 0 pass, 1 fail.",
|
||||
"check:popup-href-escaped": "a URL interpolated into an HTML string is escaped before it lands in href= or src=. Written for RegionCountryMap's popup, which escaped every value except its two link URLs -- both built from joinBase(hostAttribute, apiSlug), so a quote in either escaped the attribute. Data URLs, blob URLs, encodeURIComponent and safeUrl() are recognised as safe; anything else needs an href-escaped-ok: reason. 6 self-tests. Source-only, headless.",
|
||||
"check:poster-action-print": "a poster button opens the print dialog (Driver rule 2026-09-28: never a direct download). Two arms: PosterWall's posterAction defaults to 'print' and any consumer opting into 'download' carries a posterAction-download: comment saying why; and no shipped file hand-rolls an <a download> for a poster IMAGE (svg/png/webp/pdf) without first attempting to print, which the prop arm cannot see. Data exports (csv/txt/json) are out of scope. 6 self-tests. Source-only, headless.",
|
||||
"check:print-frame-reclaim": "a print frame is kept until it has printed: openPrintFrame reclaims it 60 s after its load (not after its creation), at afterprint, or at a 10-minute ceiling, and printImageBlob revokes its object URL on the same clock. Fake timers; two-sided (the pre-fix copy fails). The Wall poster loads 132 MB of photos, and the old 60 s-from-creation clock removed the frame before it could print.",
|
||||
"check:poster-attribution": "the poster footer is a LICENCE statement, so it must credit whoever actually served the tiles. posterDefaults ships '© Mapbox © OpenStreetMap', which under the fleet's MapLibre default credits a company that served nothing. Checked live 2026-09-28: the OpenFreeMap STYLE declares no attribution on either source — the credit is in the TileJSON, merged into the SOURCE object after load — so it is read from map.getSource(id), flattened from HTML, deduped, and an unreadable map falls back rather than printing a blank line. Headless; in gates:pre.",
|
||||
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
|
||||
"check:poster-degrades": "with NEITHER poster nor source — the DEFAULT drop — nothing must print and the reader must be told why. check:poster-print-fallback SKIPs that case, so the behaviour a reader most often meets had no gate. MUST be served from the TREE ROOT, not public/, or assetsBase resolves outside the tree and the poster loads when it should 404.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue