Phone print: going away again cancels a pending drop; a 60 s grace; no early touch
A second read measured a hole in the phone print keep: a touch after print() started the grace, and if the print UI then opened (the page hidden, or the window blurred), nothing cancelled it, so the document could go while the reader was still choosing Save as PDF. Now: - going away again during the grace cancels the pending drop, and the next "back" counts; - BACK_GRACE_MS is 60 s (was 5 s): a slow phone can still be writing an 11-page packet after its dialog closes, and the kept document is invisible on screen and replaced by the next print; - a touch in the first TOUCH_SETTLE_MS (2 s) after print() is not "back" (a double tap while the preview builds). check:phone-print-keep gains arm H (a touch, then the print UI, by hiding and by blur: still mounted past the grace, released after the next back) and arm I (a touch in the settle window), with the poisons "no cancel when away again" and "no touch settle window"; arm C now taps Print a few seconds after the document is ready and stays in the dialog past the grace. The first cut fails H and I; the pilot's file fails A to H. Desktop stays byte-identical.
This commit is contained in:
parent
0b1fcde1db
commit
967457f65e
3 changed files with 50 additions and 18 deletions
|
|
@ -9,7 +9,7 @@
|
||||||
* later, after the reader taps Save, from the page as it is then.
|
* later, after the reader taps Save, from the page as it is then.
|
||||||
*
|
*
|
||||||
* A timeline on fake timers and a stub DOM, with an Android user agent (Playwright cannot reproduce Android's print
|
* A timeline on fake timers and a stub DOM, with an Android user agent (Playwright cannot reproduce Android's print
|
||||||
* pipeline; the Driver's phone is the real check):
|
* pipeline; a real phone is the real check):
|
||||||
* A. after print() and afterprint, the document and its print style are still mounted 30 s on (the Save render);
|
* A. after print() and afterprint, the document and its print style are still mounted 30 s on (the Save render);
|
||||||
* B. it goes once the reader is BACK: the page shown again after being hidden, the window focused again after
|
* B. it goes once the reader is BACK: the page shown again after being hidden, the window focused again after
|
||||||
* losing focus, or a touch after print(), then BACK_GRACE_MS later, not before;
|
* losing focus, or a touch after print(), then BACK_GRACE_MS later, not before;
|
||||||
|
|
@ -18,10 +18,15 @@
|
||||||
* D. a reader who never comes back: dropped at FRAME_CEILING_MS, not before;
|
* D. a reader who never comes back: dropped at FRAME_CEILING_MS, not before;
|
||||||
* E. printImageBlob: its object URL is NOT revoked at afterprint, and is revoked when the document goes;
|
* E. printImageBlob: its object URL is NOT revoked at afterprint, and is revoked when the document goes;
|
||||||
* F. a new print replaces the previous document (one root mounted, and the previous one released);
|
* F. a new print replaces the previous document (one root mounted, and the previous one released);
|
||||||
* G. the desktop path is the frame, as before (printDocument's first line, verbatim).
|
* G. the desktop path is the frame, as before (printDocument's first line, verbatim);
|
||||||
|
* H. a touch AFTER the first TOUCH_SETTLE_MS, then the print UI opening (the page hidden, or the window blurred):
|
||||||
|
* the pending drop is cancelled, so the document is still mounted well past BACK_GRACE_MS in the UI, and goes
|
||||||
|
* after the next "back" (a measured hole in the first cut, found on a second read);
|
||||||
|
* I. a touch in the first TOUCH_SETTLE_MS after print() (a double tap while the preview builds) is not "back".
|
||||||
* `--impl=<path>` runs the timeline against another copy (the pilot's, before this fix, fails A and E: the cause).
|
* `--impl=<path>` runs the timeline against another copy (the pilot's, before this fix, fails A and E: the cause).
|
||||||
* `--self-test`: the old drop at afterprint, no release when the reader is back, the listeners armed at mount (so the
|
* `--self-test`: the old drop at afterprint, no release when the reader is back, the listeners armed at mount (so the
|
||||||
* Print tap drops it), no grace, printImageBlob revoking at afterprint, and no ceiling must each FAIL.
|
* Print tap drops it), no grace, printImageBlob revoking at afterprint, no ceiling, no cancel when the page goes away
|
||||||
|
* again during the grace, and no touch settle window must each FAIL.
|
||||||
*/
|
*/
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { join, dirname } from 'node:path'
|
import { join, dirname } from 'node:path'
|
||||||
|
|
@ -96,8 +101,8 @@ const load = (src) => import('data:text/javascript;base64,' + Buffer.from(src +
|
||||||
async function audit(src) {
|
async function audit(src) {
|
||||||
const fails = []
|
const fails = []
|
||||||
const sample = await load(src)
|
const sample = await load(src)
|
||||||
const FRAME_CEILING_MS = sample.FRAME_CEILING_MS || 600000, BACK_GRACE_MS = sample.BACK_GRACE_MS || 0
|
const FRAME_CEILING_MS = sample.FRAME_CEILING_MS || 600000, BACK_GRACE_MS = sample.BACK_GRACE_MS || 0, TOUCH_SETTLE_MS = sample.TOUCH_SETTLE_MS || 0
|
||||||
if (!sample.FRAME_CEILING_MS || !sample.BACK_GRACE_MS) fails.push('G: printInPlace.js does not export FRAME_CEILING_MS and BACK_GRACE_MS (the timeline runs on, with a grace of 0)')
|
if (!sample.FRAME_CEILING_MS || !sample.BACK_GRACE_MS || !sample.TOUCH_SETTLE_MS) fails.push('G: printInPlace.js does not export FRAME_CEILING_MS, BACK_GRACE_MS and TOUCH_SETTLE_MS (the timeline runs on, with 0 for each missing one)')
|
||||||
const HTML = '<!doctype html><html><head><style>@page{size:letter portrait}</style></head><body><div>packet</div></body></html>'
|
const HTML = '<!doctype html><html><head><style>@page{size:letter portrait}</style></head><body><div>packet</div></body></html>'
|
||||||
|
|
||||||
// A + B (hidden → visible)
|
// A + B (hidden → visible)
|
||||||
|
|
@ -117,16 +122,17 @@ async function audit(src) {
|
||||||
if (!mounted()) fails.push('B: the document went while the window had lost focus')
|
if (!mounted()) fails.push('B: the document went while the window had lost focus')
|
||||||
window.fire('focus'); advance(BACK_GRACE_MS + 1)
|
window.fire('focus'); advance(BACK_GRACE_MS + 1)
|
||||||
if (mounted()) fails.push('B: the document stayed after the window was focused again (back on the page)') }
|
if (mounted()) fails.push('B: the document stayed after the window was focused again (back on the page)') }
|
||||||
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(5000); window.fire('pointerdown'); advance(BACK_GRACE_MS + 1)
|
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(TOUCH_SETTLE_MS + 3000); window.fire('pointerdown'); advance(BACK_GRACE_MS + 1)
|
||||||
if (mounted()) fails.push('B: the document stayed after the page was touched again') }
|
if (mounted()) fails.push('B: the document stayed after the page was touched again') }
|
||||||
// C: a late print (the tap's activation expired): the Print tap is not "back"
|
// C: a late print (the tap's activation expired): the Print tap is not "back"
|
||||||
{ world({ active: false }); const m = await load(src); let printNow = null
|
{ world({ active: false }); const m = await load(src); let printNow = null
|
||||||
const how = await m.printDocument(HTML, { onNeedsTap: (p) => { printNow = p } })
|
const how = await m.printDocument(HTML, { onNeedsTap: (p) => { printNow = p } })
|
||||||
if (how !== 'needs-tap' || !printNow) fails.push(`C: an expired tap did not hand out printNow (returned ${how})`)
|
if (how !== 'needs-tap' || !printNow) fails.push(`C: an expired tap did not hand out printNow (returned ${how})`)
|
||||||
else {
|
else {
|
||||||
window.fire('pointerdown'); printNow(); window.fire('afterprint'); advance(30000)
|
// the reader taps Print a few seconds after the document is ready, then spends longer than the grace in the dialog
|
||||||
|
advance(TOUCH_SETTLE_MS + 3000); window.fire('pointerdown'); printNow(); window.fire('afterprint'); advance(BACK_GRACE_MS + 20000)
|
||||||
if (window.prints.length !== 1 || !window.prints[0]) fails.push('C: printNow did not print the mounted document')
|
if (window.prints.length !== 1 || !window.prints[0]) fails.push('C: printNow did not print the mounted document')
|
||||||
if (!mounted()) fails.push('C: the Print tap counted as being back: the document was gone 30 s into the dialog')
|
if (!mounted()) fails.push(`C: the Print tap counted as being back: the document was gone ${(BACK_GRACE_MS + 20000) / 1000} s into the dialog`)
|
||||||
} }
|
} }
|
||||||
// D: never back
|
// D: never back
|
||||||
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(FRAME_CEILING_MS - 1)
|
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(FRAME_CEILING_MS - 1)
|
||||||
|
|
@ -145,6 +151,21 @@ async function audit(src) {
|
||||||
await m.printDocument(HTML, { onRelease: () => { first++ } }); window.fire('afterprint'); await m.printDocument(HTML)
|
await m.printDocument(HTML, { onRelease: () => { first++ } }); window.fire('afterprint'); await m.printDocument(HTML)
|
||||||
if (rootCount() !== 1) fails.push(`F: ${rootCount()} print roots mounted after a second print (expected 1)`)
|
if (rootCount() !== 1) fails.push(`F: ${rootCount()} print roots mounted after a second print (expected 1)`)
|
||||||
if (first !== 1) fails.push(`F: the first print's onRelease ran ${first} time(s) when a second print replaced it (expected 1)`) }
|
if (first !== 1) fails.push(`F: the first print's onRelease ran ${first} time(s) when a second print replaced it (expected 1)`) }
|
||||||
|
// H: a touch after the settle window, then the print UI opens: away again cancels the pending drop
|
||||||
|
for (const via of ['hidden', 'blur']) {
|
||||||
|
world(); const m = await load(src); await m.printDocument(HTML); advance(TOUCH_SETTLE_MS + 500); window.fire('pointerdown'); advance(500)
|
||||||
|
if (via === 'hidden') { document.visibilityState = 'hidden'; document.fire('visibilitychange') } else window.fire('blur')
|
||||||
|
window.fire('afterprint'); advance(BACK_GRACE_MS + 20000)
|
||||||
|
if (!mounted()) fails.push(`H: a touch, then the print UI (${via}): the grace the touch started was not cancelled, and the document went while the reader was still in the UI (a PDF saved then is the bare page)`)
|
||||||
|
else {
|
||||||
|
if (via === 'hidden') { document.visibilityState = 'visible'; document.fire('visibilitychange') } else window.fire('focus')
|
||||||
|
advance(BACK_GRACE_MS + 1)
|
||||||
|
if (mounted()) fails.push(`H: after the print UI (${via}) closed, the document stayed past the grace (the next "back" did not count)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// I: a touch in the settle window (no hide, no blur: an in-page sheet) is not "back"
|
||||||
|
{ world(); const m = await load(src); await m.printDocument(HTML); advance(Math.max(1, TOUCH_SETTLE_MS - 1500)); window.fire('pointerdown'); advance(BACK_GRACE_MS + 30000)
|
||||||
|
if (!mounted()) fails.push('I: a touch right after print() (a double tap while the preview builds) counted as being back, and the document went with the dialog still up') }
|
||||||
// G: desktop unchanged (the frame path)
|
// G: desktop unchanged (the frame path)
|
||||||
if (!src.includes("if (!isPhonePrint()) return openPrintFrame(html, { autoprint: true }) ? 'frame' : 'blocked'")) fails.push('G: printDocument\'s desktop path is no longer the frame, verbatim')
|
if (!src.includes("if (!isPhonePrint()) return openPrintFrame(html, { autoprint: true }) ? 'frame' : 'blocked'")) fails.push('G: printDocument\'s desktop path is no longer the frame, verbatim')
|
||||||
return fails
|
return fails
|
||||||
|
|
@ -160,6 +181,8 @@ if (process.argv.includes('--self-test')) {
|
||||||
['no grace once the reader is back', src.replace('ceiling = setTimeout(drop, BACK_GRACE_MS) }', 'drop() }'), /^B: the document went at once/],
|
['no grace once the reader is back', src.replace('ceiling = setTimeout(drop, BACK_GRACE_MS) }', 'drop() }'), /^B: the document went at once/],
|
||||||
['printImageBlob revoking at afterprint', src.replace(" const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n", " const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n window.addEventListener('afterprint', () => setTimeout(release, 0))\n"), /^E: printImageBlob revoked/],
|
['printImageBlob revoking at afterprint', src.replace(" const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n", " const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n window.addEventListener('afterprint', () => setTimeout(release, 0))\n"), /^E: printImageBlob revoked/],
|
||||||
['no ceiling', src.replace(MOUNT_END, ' currentJob = job\n'), /^D: the document outlived/],
|
['no ceiling', src.replace(MOUNT_END, ' currentJob = job\n'), /^D: the document outlived/],
|
||||||
|
['no cancel when the page goes away again', src.replace('const awayAgain = () => { away = true; if (leaving) { leaving = false; clearTimeout(ceiling); ceiling = setTimeout(drop, FRAME_CEILING_MS) } }', 'const awayAgain = () => { away = true }'), /^H: a touch, then the print UI/],
|
||||||
|
['no touch settle window', src.replace('const onTouch = () => { if (!settling) back() }', 'const onTouch = () => back()'), /^I: /],
|
||||||
]
|
]
|
||||||
let missed = 0
|
let missed = 0
|
||||||
for (const [name, poisoned, rx] of poisons) {
|
for (const [name, poisoned, rx] of poisons) {
|
||||||
|
|
@ -172,5 +195,5 @@ if (process.argv.includes('--self-test')) {
|
||||||
} else {
|
} else {
|
||||||
const f = await audit(src)
|
const f = await audit(src)
|
||||||
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:phone-print-keep (${f.length})`); process.exitCode = 1 }
|
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:phone-print-keep (${f.length})`); process.exitCode = 1 }
|
||||||
else console.log('PASS check:phone-print-keep — on a phone the print document (and printImageBlob\'s image) outlives afterprint, so a PDF saved after the preview is the document; it goes once the reader is back (shown, focused or touched again) plus a grace, or at the ceiling; the Print tap of a late print does not count; a new print replaces it; desktop prints through the frame as before')
|
else console.log('PASS check:phone-print-keep — on a phone the print document (and printImageBlob\'s image) outlives afterprint, so a PDF saved after the preview is the document; it goes once the reader is back (shown, focused or touched again) plus a grace, or at the ceiling; the Print tap of a late print and a touch right after print() do not count; going away again cancels a pending drop; a new print replaces it; desktop prints through the frame as before')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -179,21 +179,27 @@ function mountPrintRoot(html, { onRelease = null } = {}) {
|
||||||
// ★ KEPT UNTIL THE READER IS BACK, never dropped at afterprint (printDocument says why). From the moment print() is
|
// ★ KEPT UNTIL THE READER IS BACK, never dropped at afterprint (printDocument says why). From the moment print() is
|
||||||
// called (printing(), not at mount: the Print tap that starts a late print must not count), the reader is back when
|
// called (printing(), not at mount: the Print tap that starts a late print must not count), the reader is back when
|
||||||
// the page is shown again after being hidden, the window is focused again after losing focus, or the page is
|
// the page is shown again after being hidden, the window is focused again after losing focus, or the page is
|
||||||
// touched (the system print UI covers the page, so a touch there means it has gone). It goes BACK_GRACE_MS after
|
// touched (the system print UI covers the page, so a touch there means it has gone). A touch in the first
|
||||||
// that, since a print job can still be rendering while the dialog animates away. A new print replaces it, a
|
// TOUCH_SETTLE_MS after print() does not count (a double tap while the preview builds). It goes BACK_GRACE_MS after
|
||||||
// cancelled one drops it, and FRAME_CEILING_MS is the ceiling. On screen it is display:none, so keeping it shows nothing.
|
// that, since a slow phone can still be writing the file after its dialog closes; if the page goes AWAY again during
|
||||||
let ceiling = null, armed = false, away = false, leaving = false
|
// the grace (the print UI opened after an early touch), the pending drop is cancelled and the next "back" counts. A
|
||||||
|
// new print replaces it, a cancelled one drops it, and FRAME_CEILING_MS is the ceiling. On screen it is display:none,
|
||||||
|
// so keeping it shows nothing.
|
||||||
|
let ceiling = null, settle = null, armed = false, away = false, leaving = false, settling = false
|
||||||
const back = () => { if (leaving) return; leaving = true; clearTimeout(ceiling); ceiling = setTimeout(drop, BACK_GRACE_MS) }
|
const back = () => { if (leaving) return; leaving = true; clearTimeout(ceiling); ceiling = setTimeout(drop, BACK_GRACE_MS) }
|
||||||
const onVisibility = () => { if (document.visibilityState === 'hidden') away = true; else if (away) back() }
|
const awayAgain = () => { away = true; if (leaving) { leaving = false; clearTimeout(ceiling); ceiling = setTimeout(drop, FRAME_CEILING_MS) } }
|
||||||
const onBlur = () => { away = true }
|
const onVisibility = () => { if (document.visibilityState === 'hidden') awayAgain(); else if (away) back() }
|
||||||
|
const onBlur = () => { awayAgain() }
|
||||||
const onFocus = () => { if (away) back() }
|
const onFocus = () => { if (away) back() }
|
||||||
const onTouch = () => back()
|
const onTouch = () => { if (!settling) back() }
|
||||||
const job = {
|
const job = {
|
||||||
live: () => currentJob === job,
|
live: () => currentJob === job,
|
||||||
drop,
|
drop,
|
||||||
printing: () => {
|
printing: () => {
|
||||||
if (armed) return
|
if (armed) return
|
||||||
armed = true
|
armed = true
|
||||||
|
settling = true
|
||||||
|
settle = setTimeout(() => { settling = false }, TOUCH_SETTLE_MS)
|
||||||
document.addEventListener('visibilitychange', onVisibility)
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
window.addEventListener('blur', onBlur)
|
window.addEventListener('blur', onBlur)
|
||||||
window.addEventListener('focus', onFocus)
|
window.addEventListener('focus', onFocus)
|
||||||
|
|
@ -211,6 +217,7 @@ function mountPrintRoot(html, { onRelease = null } = {}) {
|
||||||
}
|
}
|
||||||
function drop() {
|
function drop() {
|
||||||
clearTimeout(ceiling)
|
clearTimeout(ceiling)
|
||||||
|
clearTimeout(settle)
|
||||||
document.removeEventListener('visibilitychange', onVisibility)
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
window.removeEventListener('blur', onBlur)
|
window.removeEventListener('blur', onBlur)
|
||||||
window.removeEventListener('focus', onFocus)
|
window.removeEventListener('focus', onFocus)
|
||||||
|
|
@ -230,7 +237,9 @@ export const FRAME_KEEP_MS = 60000
|
||||||
/** The absolute ceiling for a print frame whose load never ends. */
|
/** The absolute ceiling for a print frame whose load never ends. */
|
||||||
export const FRAME_CEILING_MS = 600000
|
export const FRAME_CEILING_MS = 600000
|
||||||
/** A phone's in-page print document goes this long after the reader is back on the page (mountPrintRoot). */
|
/** A phone's in-page print document goes this long after the reader is back on the page (mountPrintRoot). */
|
||||||
export const BACK_GRACE_MS = 5000
|
export const BACK_GRACE_MS = 60000
|
||||||
|
/** A touch this soon after print() is not the reader coming back (a double tap while the preview builds). */
|
||||||
|
export const TOUCH_SETTLE_MS = 2000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print a rendered poster IMAGE, at its real paper size, through the same in-place frame.
|
* Print a rendered poster IMAGE, at its real paper size, through the same in-place frame.
|
||||||
|
|
|
||||||
|
|
@ -438,7 +438,7 @@
|
||||||
"check:phone-print-keep:self-test": "node internal/check-phone-print-keep.mjs --self-test"
|
"check:phone-print-keep:self-test": "node internal/check-phone-print-keep.mjs --self-test"
|
||||||
},
|
},
|
||||||
"//scripts": {
|
"//scripts": {
|
||||||
"check:phone-print-keep": "on a PHONE the in-page print document (#dxm-print-root and its print style, library/composables/printInPlace.js) and printImageBlob's object URL outlive afterprint, because Android Chrome fires afterprint once the PREVIEW is built and renders a saved PDF later, from the page as it is then (a phone's saved packet was the bare host page). A timeline on fake timers and a stub DOM with an Android user agent: still mounted 30 s after afterprint; released once the reader is back (shown again after hidden, focused again after blur, or touched after print()) plus BACK_GRACE_MS, or at the ceiling; the Print tap of a late print does not count; a new print replaces it; desktop is the frame path, verbatim. --impl=<path> runs it against another copy (the pre-fix file fails). Self-test poisons: the old drop at afterprint, no release when back, listeners armed at mount, no grace, printImageBlob revoking at afterprint, no ceiling.",
|
"check:phone-print-keep": "on a PHONE the in-page print document (#dxm-print-root and its print style, library/composables/printInPlace.js) and printImageBlob's object URL outlive afterprint, because Android Chrome fires afterprint once the PREVIEW is built and renders a saved PDF later, from the page as it is then (a phone's saved packet was the bare host page). A timeline on fake timers and a stub DOM with an Android user agent: still mounted 30 s after afterprint; released once the reader is back (shown again after hidden, focused again after blur, or touched after print() but not in its first TOUCH_SETTLE_MS) plus BACK_GRACE_MS (60 s), or at the ceiling; going away again during the grace cancels the pending drop; the Print tap of a late print does not count; a new print replaces it; desktop is the frame path, verbatim. --impl=<path> runs it against another copy (the pre-fix file fails). Self-test poisons: the old drop at afterprint, no release when back, listeners armed at mount, no grace, printImageBlob revoking at afterprint, no ceiling, no cancel when away again, no touch settle window.",
|
||||||
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
|
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
|
||||||
"browser-gates": "lists every package.json script that launches a browser (the same detection run-parallel uses to route them through internal/browser-slot.sh). Run each as internal/browser-slot.sh bun run <name>.",
|
"browser-gates": "lists every package.json script that launches a browser (the same detection run-parallel uses to route them through internal/browser-slot.sh). Run each as internal/browser-slot.sh bun run <name>.",
|
||||||
"cap:sync": "NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.",
|
"cap:sync": "NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue