diff --git a/src/Engine/WorldModel.cs b/src/Engine/WorldModel.cs index 2a6b91dfc..5ea02d048 100644 --- a/src/Engine/WorldModel.cs +++ b/src/Engine/WorldModel.cs @@ -1186,7 +1186,17 @@ internal void SignalTurnSuspended(bool scroll = true) { if (scroll && Version >= WorldModelVersion.v540) { + // ScrollToEnd() must run first: it targets wherever MarkScrollPosition() + // was last called (the *start* of this turn's own output, set either by + // this method after the previous turn or by player.js/beginWait() for an + // interactive command/mid-turn wait). Only once that's queued do we + // record where this turn's output ends, ready as the target for + // whichever turn comes next — interactive command, walkthrough step, + // timer, or wait/ask/menu resumption, since they all funnel through this + // one hook (unlike the player.js/beginWait() JS-side calls, which only + // cover interactive typing and mid-turn waits). ScrollToEnd(); + MarkScrollPosition(); } // Every wait()/get input()/ask/show menu call site pairs BeginPendingCallback/ // EndPendingCallbackAsync 1:1 with a SignalTurnSuspended call, and the two @@ -1585,6 +1595,11 @@ private void ScrollToEnd() _ = PlayerUi.RunScriptAsync("scrollToEnd", null); } + private void MarkScrollPosition() + { + _ = PlayerUi.RunScriptAsync("markScrollPosition", null); + } + internal void LogException(Exception ex) { LogError?.Invoke(ex); diff --git a/src/PlayerCore/Resources/playercore.css b/src/PlayerCore/Resources/playercore.css index 02a666a93..461e36eb6 100644 --- a/src/PlayerCore/Resources/playercore.css +++ b/src/PlayerCore/Resources/playercore.css @@ -2,6 +2,19 @@ font-family: Arial, Helvetica, sans-serif; overflow-y: scroll; background-attachment: fixed; + /* playercore.js explicitly recomputes and sets scroll position itself on + every relevant DOM change (new turn, picture frame resizing — see + scrollToTurnStart()/setPanelHeight() in playercore.js), including + content inserted above the current scroll position (the sticky + picture frame growing once its image loads). Chrome's default scroll + anchoring tries to do the same job automatically to avoid a visual + jump, but its guess and playercore.js's own (more informed) target + can disagree — and since anchoring applies *before* playercore.js's + own correction runs, its adjustment becomes the new "current scroll + position" that scrollToTurnStart()'s never-scroll-backward guard then + treats as intentional, silently defeating the real correction. + Disabling it leaves scroll positioning entirely to our own logic. */ + overflow-anchor: none; } html, diff --git a/src/PlayerCore/Resources/playercore.js b/src/PlayerCore/Resources/playercore.js index 093c6fbe4..41a4a094e 100644 --- a/src/PlayerCore/Resources/playercore.js +++ b/src/PlayerCore/Resources/playercore.js @@ -283,10 +283,22 @@ function initPlayerUI() { } }; + // Fires from the picture frame's (SetFramePicture/ + // JS.setPanelContents) once the image has actually finished loading and + // #gamePanel has its real, final height — before that, stickyOverlayHeight() + // only sees whatever height an unloaded renders at (effectively 0), + // so the turn's own scrollToEnd() call (fired the moment its text and the + // tag are added, well before the image itself has loaded) can + // under-count the frame and land the turn's opening line where the + // picture is about to appear. Re-targets scrollToTurnStart() at + // lastScrollTurnStart — a snapshot of whichever turn start that original + // call used — rather than re-reading beginningOfCurrentTurnScrollPosition, + // which markScrollPosition() has typically already advanced to the next + // turn's start by the time a real image finishes loading. window.setPanelHeight = function () { if (_showGrid) return; setTimeout(function () { - scrollToEnd(); + scrollToTurnStart(lastScrollTurnStart); }, 100); }; @@ -495,13 +507,132 @@ function isElementVisible(element) { var _animateScroll = true; -function scrollToEnd() { - if (!_animateScroll) { - $('html,body').scrollTop(document.body.scrollHeight); - } else { - $('html,body').animate({scrollTop: document.body.scrollHeight}, 'fast'); +// The page's own scrollable content sits behind #qv-status (fixed) and +// whichever of #gamePanel/#gridPanel (sticky — the static picture frame +// feature, e.g. "The Shack") is showing a picture. Once scrolled past their +// natural flow position they float on top of whatever's beneath them, so a +// scroll target that ignores them can land content behind the frame instead +// of below it. #gamePanel/#gridPanel's own "top" CSS already accounts for +// #qv-status's height (see updateStatusVisibility()), so taking the deepest +// bottom edge of the three — rather than summing them — avoids double +// counting #qv-status when a picture is also showing. +function stickyOverlayHeight() { + var bottom = 0; + ["#qv-status", "#gamePanel", "#gridPanel"].forEach(function (selector) { + var $el = $(selector); + if ($el.length && isElementVisible($el)) { + bottom = Math.max(bottom, (parseFloat($el.css("top")) || 0) + $el.outerHeight()); + } + }); + return bottom; +} + +// Snapshot of whichever turn-start position the most recent scrollToEnd() +// call actually used, kept separately from beginningOfCurrentTurnScrollPosition +// — see setPanelHeight()'s doc comment (above, in initPlayerUI()) for why. +var lastScrollTurnStart = 0; + +// Scrolls just far enough that turnStart (the top of some turn's output) +// lands below the fixed/sticky chrome at the top of the viewport, rather than +// jumping straight to the bottom of the document — otherwise a long turn's +// opening lines scroll off the top (or behind the status bar/picture frame) +// before the player can read them. Never scrolls backward past wherever the +// player currently is (e.g. if they've scrolled up to reread), and never +// past the true bottom of the document. +// +// A turn can emit several OutputText calls, each triggering scrollToEnd(), +// and — for a walkthrough or any other rapid burst of turns — the next one +// can easily land while the previous call's animation is still running. +// easeInOutCubic decelerates to a near-stop at the end of every animation; +// restarting that curve from scratch on each interruption made the scroll +// visibly stutter (slow down, barely move, speed up, slow down again) rather +// than glide, with the final leg then covering whatever distance was left in +// one comparatively large jump once nothing interrupted it further. Only the +// very first call in a burst (starting from a stationary page) gets the +// eased curve; anything that interrupts an already-running scroll continues +// at the same steady rate instead of re-decelerating and re-accelerating. +// +// allowBackward (default false) lets the target move the scroll position +// *up*, not just down — normally refused so that a manual scroll-up to +// reread history doesn't get yanked back down by an unrelated later turn. +// clearScreen() passes true: a turn that clears the screen can still hit an +// ordinary turn-boundary scrollToEnd() call using the *pre-clear* turn start +// (SignalTurnSuspended's own call, queued before the script that called +// ClearScreen has necessarily finished, can still be based on wherever the +// old, now-discarded page happened to end) — clamped against the old, +// now-irrelevant document height, that can scroll to a wrong, much-too-far +// position before clearScreen's own correction below runs. Since the entire +// point of a clear is "nothing before this matters any more", its own +// correction must be allowed to override that, forward or backward. +function scrollToTurnStart(turnStart, allowBackward) { + lastScrollTurnStart = turnStart; + + // A picture frame image that hasn't finished loading yet (successfully + // or not) doesn't have its real size yet — #gamePanel/#gridPanel's + // height right now is whatever an unloaded renders at + // (effectively 0), so any target computed against it would undercount + // the frame and visibly place content where the picture is about to + // appear, needing a jump to fix once it loads. Skip for now instead: + // setPanelContents() wires up a load/error listener on every panel + // image (on top of SetFramePicture's own ) that calls back + // into here once the frame's real height is known, so this is never + // left stuck — just deferred until the answer is actually right. + var pendingImg = $("#gamePanel:visible img, #gridPanel:visible img").filter(function () { + return !this.complete; + })[0]; + if (pendingImg) { + return; } - $("#txtCommand").focus(); + + if (!_animateScroll) { + // Used while fast-forwarding a walkthrough — always jump straight to + // the true end rather than following the turn-by-turn target below. + $('html,body').stop(true, false).scrollTop(document.body.scrollHeight); + focusCommandInput(); + return; + } + + var headerHeight = stickyOverlayHeight(); + // turnStart is measured relative to #gameContent's own top (its height() + // when markScrollPosition() ran), not the page's — #gameContent isn't + // necessarily at page-top itself, since #gamePanel/#gridPanel (sticky, + // but still flow-participating) reserve real layout space above it + // whenever a picture is showing. Skipping this offset used to cancel out + // by accident whenever the frame was already showing before the turn + // started (its contribution to both this offset and headerHeight above + // were equal), which is why it went unnoticed until the frame's size + // changed *after* the turn had already been positioned. + var pageTurnStart = $("#gameContent").offset().top + turnStart; + var maxScrollTop = Math.max($(document).height() - $(window).height(), 0); + var target = Math.min(Math.max(pageTurnStart - headerHeight - 20, 0), maxScrollTop); + var currentScrollTop = Math.max($("body").scrollTop(), $("html").scrollTop()); + + if (target !== currentScrollTop && (allowBackward || target > currentScrollTop)) { + var wasAlreadyScrolling = $('html,body').is(":animated"); + var distance = Math.abs(target - currentScrollTop); + var duration = Math.min(distance / 0.4, 2000); + var easing = wasAlreadyScrolling ? "linear" : "easeInOutCubic"; + $('html,body').stop(true, false).animate({scrollTop: target}, duration, easing); + } + focusCommandInput(); +} + +// Plain jQuery .focus() calls the native HTMLElement.focus(), which — for an +// element not already focused — asks the browser to scroll it into view. +// #txtCommand sits right after all of #divOutput, so whenever it isn't +// already focused (page just loaded, on mobile before the on-screen keyboard +// has been tapped, focus lost to a click/selection elsewhere) that silently +// jumped straight to the document's bottom, undoing every calculation above. +// {preventScroll: true} keeps the input usable (typing still works +// immediately) without fighting the scroll position we just computed. +function focusCommandInput() { + document.getElementById("txtCommand")?.focus({preventScroll: true}); +} + +// beginningOfCurrentTurnScrollPosition is set by markScrollPosition() before +// the turn began (see its own doc comment). +function scrollToEnd() { + scrollToTurnStart(beginningOfCurrentTurnScrollPosition); } function SetAnimateScroll(value) { @@ -538,6 +669,12 @@ function setPanelContents(html) { $("#gamePanel").hide() } $("#gamePanel").html(html); + // Belt-and-braces alongside SetFramePicture's own + // markup: guarantees a retry once any image here finishes loading (success + // or failure) even for panel HTML set directly via JS.setPanelContents that + // doesn't happen to include that onload itself — scrollToTurnStart() below + // relies on one of these eventually firing to know it's safe to scroll. + $("#gamePanel img").one("load error", setPanelHeight); setPanelHeight(); } @@ -920,8 +1057,18 @@ function clearScreen() { $("#divOutput").html(""); createNewDiv("left"); beginningOfCurrentTurnScrollPosition = 0; + // scrollToTurnStart(0), not a bare scrollTop(0) — the new page's + // content starts at #gameContent height 0, but #gameContent itself + // isn't necessarily at the very top of the *page*: a static picture + // frame (#gamePanel/#gridPanel, e.g. "The Shack") reserves real flow + // space above it, and jumping to page-scrollTop 0 only clears that + // frame if the frame is shorter than the viewport — on a small/mobile + // screen it very often isn't, leaving the new page's opening line + // rendered behind the frame. This fires on every gamebook page + // transition (DoPage → ClearScreen when game.clearlastpage), which + // includes game start (the player's first changedparent). setTimeout(function () { - $("html,body").scrollTop(0); + scrollToTurnStart(0, true); }, 100); } else { $("#divOutput").append("
"); @@ -935,7 +1082,7 @@ function clearScreen() { createNewDiv('left'); beginningOfCurrentTurnScrollPosition = 0; setTimeout(function () { - $('html,body').scrollTop(0); + scrollToTurnStart(0, true); }, 100); } $("#outputData").appendTo($("#divOutput")); diff --git a/tests/e2e/fixtures/scroll-clearscreen-after-wait-test.aslx b/tests/e2e/fixtures/scroll-clearscreen-after-wait-test.aslx new file mode 100644 index 000000000..cb72fede3 --- /dev/null +++ b/tests/e2e/fixtures/scroll-clearscreen-after-wait-test.aslx @@ -0,0 +1,67 @@ + + + + + + + + + + + gopage1 + + + + + + + + diff --git a/tests/e2e/fixtures/scroll-clearscreen-frame-test.aslx b/tests/e2e/fixtures/scroll-clearscreen-frame-test.aslx new file mode 100644 index 000000000..f50f35e65 --- /dev/null +++ b/tests/e2e/fixtures/scroll-clearscreen-frame-test.aslx @@ -0,0 +1,32 @@ + + + + + + + + + + + showframe + + + + gotopage2 + + + + + + + + diff --git a/tests/e2e/fixtures/scroll-frame-test.aslx b/tests/e2e/fixtures/scroll-frame-test.aslx new file mode 100644 index 000000000..bcc0c8c7b --- /dev/null +++ b/tests/e2e/fixtures/scroll-frame-test.aslx @@ -0,0 +1,157 @@ + + + + + + + + + + + showframe + + + + shout1 + + + + shout2 + + + + + + + + diff --git a/tests/e2e/fixtures/scroll-walkthrough-test.aslx b/tests/e2e/fixtures/scroll-walkthrough-test.aslx new file mode 100644 index 000000000..06f4a51b4 --- /dev/null +++ b/tests/e2e/fixtures/scroll-walkthrough-test.aslx @@ -0,0 +1,418 @@ + + + + + + + + + + + shout1 + + + + shout2 + + + + shout3 + + + + shout4 + + + + shout5 + + + + shout6 + + + + + + + + diff --git a/tests/e2e/verify-wasmplayer-autoscroll-clearscreen-after-wait.mjs b/tests/e2e/verify-wasmplayer-autoscroll-clearscreen-after-wait.mjs new file mode 100644 index 000000000..4ce602e5b --- /dev/null +++ b/tests/e2e/verify-wasmplayer-autoscroll-clearscreen-after-wait.mjs @@ -0,0 +1,109 @@ +// Manual verification for a stale-turnStart bug found by testing against +// the real "The Shack" (id ihxy6CUAKkarClWOh2pWag): a script that prints a +// long page, wait()s, then resumes and calls ClearScreen followed by a much +// shorter page used to leave the short page's opening line scrolled off the +// top — landing at the *old* (pre-clear) page's scroll target instead. +// +// Sequence: SignalTurnSuspended's own ordinary end-of-turn scrollToEnd() +// call still fires when the turn suspends for wait() the *second* time +// (after the resumed script's ClearScreen), but ClearScreen ran mid-script, +// so beginningOfCurrentTurnScrollPosition read by that point is whatever a +// concurrent flush left it at — in practice this reproduced as scrolling to +// the (large, now-irrelevant) old page's clamped-to-document-bottom +// position, which the "never scroll backward" guard then refused to correct +// once clearScreen()'s own delayed 100ms scrollToTurnStart(0) call (with the +// *right* answer) ran afterwards, since it was asking to scroll backward +// from where the stale call had already landed. +// +// Fixed by letting clearScreen()'s own correction move the scroll position +// backward (scrollToTurnStart(0, true)) — a screen clear makes "wherever the +// user happened to be scrolled" irrelevant, so it should always win. +// +// Fixture: tests/e2e/fixtures/scroll-clearscreen-after-wait-test.aslx's +// "gopage1" command prints ~40 lines (#page1mark at the start), wait()s, +// then on resume calls ClearScreen and prints two short lines (#page2mark). +// The debugger's walkthrough runner auto-resolves wait() calls, matching how +// The Shack's WalkthroughRunner-driven "Continue" links behave. +// +// Requires the WasmPlayer dev server running locally: +// node ../../src/WasmPlayer/dev-server.mjs +import { chromium } from 'playwright'; + +const baseUrl = process.argv[2] || 'http://localhost:5175'; + +const browser = await chromium.launch(); +let failed = false; + +function check(label, condition) { + console.log(`${condition ? 'PASS' : 'FAIL'}: ${label}`); + if (!condition) failed = true; +} + +const context = await browser.newContext({ viewport: { width: 375, height: 500 } }); +const previewPage = await context.newPage(); +previewPage.on('pageerror', err => console.log('[pageerror]', err.message)); + +const senderPage = await context.newPage(); +const fixtureUrl = `${baseUrl}/e2e-fixtures/scroll-clearscreen-after-wait-test.aslx`; +await senderPage.goto(baseUrl); + +const handoffDone = senderPage.evaluate(async (url) => { + const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer()); + await new Promise((resolve) => { + const bc = new BroadcastChannel('quest-preview'); + bc.onmessage = ({ data }) => { + if (data.type === 'ready') { + bc.postMessage({ type: 'game', bytes, filename: 'scroll-clearscreen-after-wait-test.aslx' }); + resolve(); + } + }; + }); +}, fixtureUrl); + +await previewPage.goto(`${baseUrl}/?source=editor`); +await handoffDone; + +await previewPage.waitForSelector('#txtCommand', { state: 'visible', timeout: 30000 }); +await previewPage.waitForTimeout(300); + +await previewPage.click('#cmdDebug'); +await previewPage.waitForSelector('#questVivaDebugger[open]', { timeout: 5000 }); +await previewPage.click('#qv-debugger-tabs button:text("Walkthrough")'); +await previewPage.click('#qv-debugger-list [data-item="clearafterwait"]'); +await previewPage.waitForSelector('[data-run-walkthrough="clearafterwait"]'); +await previewPage.click('[data-run-walkthrough="clearafterwait"]'); + +await previewPage.waitForFunction(() => { + const el = document.querySelector('[data-walkthrough-status]'); + return el && el.textContent && el.textContent !== 'Running…'; +}, { timeout: 15000 }); +const status = await previewPage.$eval('[data-walkthrough-status]', el => el.textContent); +check('Walkthrough runs to completion', status === 'Done'); + +await previewPage.click('#qv-debugger-close'); + +// clearScreen()'s own scroll is on a 100ms setTimeout; give the (possibly +// up-to-2s) resulting animation time to settle too. +await previewPage.waitForTimeout(2500); + +const page1Exists = await previewPage.$('#page1mark'); +check('Page 1 content was actually cleared (not just scrolled past)', page1Exists === null); + +const page2Rect = await previewPage.$eval('#page2mark', el => el.getBoundingClientRect()); +console.log(' #page2mark rect:', JSON.stringify(page2Rect)); +check( + 'Page 2 start is visible in the viewport, not scrolled off the top by a stale pre-clear target', + page2Rect.top >= 0 && page2Rect.top < 500 +); + +const scrollTop = await previewPage.evaluate(() => document.scrollingElement.scrollTop); +console.log(' final scrollTop:', scrollTop); +check('Final scroll position is near the top of the (now very short) cleared page', scrollTop < 100); + +await browser.close(); + +if (failed) { + console.error('\nFAIL: one or more checks failed.'); + process.exit(1); +} +console.log('\nPASS'); diff --git a/tests/e2e/verify-wasmplayer-autoscroll-clearscreen-frame.mjs b/tests/e2e/verify-wasmplayer-autoscroll-clearscreen-frame.mjs new file mode 100644 index 000000000..4d0e63eeb --- /dev/null +++ b/tests/e2e/verify-wasmplayer-autoscroll-clearscreen-frame.mjs @@ -0,0 +1,101 @@ +// Manual verification that ClearScreen() (JS.clearScreen(), used by gamebook +// mode's DoPage on every page transition — including game start, via +// defaultplayer's changedparent — whenever game.clearlastpage is set, e.g. +// "The Shack"'s Continue links) doesn't leave the new page's opening line +// hidden behind a tall static picture frame (#gamePanel). +// +// clearScreen() used to hard-jump to page scrollTop 0 regardless of the +// frame. That's only correct if the frame is shorter than the viewport; on +// a small/mobile screen a full-width picture very often isn't, so the new +// page's first line rendered behind it. Fixed by routing through +// scrollToTurnStart(0) — the same "reveal what's below the sticky chrome" +// logic normal turns already use — instead of a bare scrollTop(0). +// +// Requires the WasmPlayer dev server running locally: +// node ../../src/WasmPlayer/dev-server.mjs +import { chromium } from 'playwright'; + +const baseUrl = process.argv[2] || 'http://localhost:5175'; + +const browser = await chromium.launch(); +let failed = false; + +function check(label, condition) { + console.log(`${condition ? 'PASS' : 'FAIL'}: ${label}`); + if (!condition) failed = true; +} + +// Small/mobile-sized viewport, matching the user's report that this is +// specifically where it's noticeable. The fixture's frame is 200px tall — +// well under the real feature's own cap (div#gamePanel img's max-height is +// dynamically set to 50% of window height, see updatePanelImageMaxHeight() +// in playercore.js), so it fits under this 500px viewport with room to +// spare, same as a real picture would. +const context = await browser.newContext({ viewport: { width: 375, height: 500 } }); +const previewPage = await context.newPage(); +previewPage.on('pageerror', err => console.log('[pageerror]', err.message)); + +const senderPage = await context.newPage(); +const fixtureUrl = `${baseUrl}/e2e-fixtures/scroll-clearscreen-frame-test.aslx`; +await senderPage.goto(baseUrl); + +const handoffDone = senderPage.evaluate(async (url) => { + const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer()); + await new Promise((resolve) => { + const bc = new BroadcastChannel('quest-preview'); + bc.onmessage = ({ data }) => { + if (data.type === 'ready') { + bc.postMessage({ type: 'game', bytes, filename: 'scroll-clearscreen-frame-test.aslx' }); + resolve(); + } + }; + }); +}, fixtureUrl); + +await previewPage.goto(`${baseUrl}/?source=editor`); +await handoffDone; + +await previewPage.waitForSelector('#txtCommand', { state: 'visible', timeout: 30000 }); +await previewPage.waitForTimeout(300); + +await previewPage.click('#cmdDebug'); +await previewPage.waitForSelector('#questVivaDebugger[open]', { timeout: 5000 }); +await previewPage.click('#qv-debugger-tabs button:text("Walkthrough")'); +await previewPage.click('#qv-debugger-list [data-item="clearwithframe"]'); +await previewPage.waitForSelector('[data-run-walkthrough="clearwithframe"]'); +await previewPage.click('[data-run-walkthrough="clearwithframe"]'); + +await previewPage.waitForFunction(() => { + const el = document.querySelector('[data-walkthrough-status]'); + return el && el.textContent && el.textContent !== 'Running…'; +}, { timeout: 15000 }); +const status = await previewPage.$eval('[data-walkthrough-status]', el => el.textContent); +check('Walkthrough runs to completion', status === 'Done'); + +await previewPage.click('#qv-debugger-close'); + +// clearScreen()'s own scroll is on a 100ms setTimeout; give the (possibly +// up-to-2s) resulting animation time to settle too. +await previewPage.waitForTimeout(2500); + +const frameRect = await previewPage.$eval('#gamePanel', el => el.getBoundingClientRect()); +const pageMarkRect = await previewPage.$eval('#page2mark', el => el.getBoundingClientRect()); +console.log(' #gamePanel rect:', JSON.stringify(frameRect)); +console.log(' #page2mark rect:', JSON.stringify(pageMarkRect)); + +check( + 'Page 2 start is visible in the viewport at all', + pageMarkRect.top >= 0 && pageMarkRect.top < 500 +); +check( + 'Page 2 start is not hidden behind the sticky picture frame after ClearScreen', + pageMarkRect.top >= frameRect.bottom +); + +await browser.close(); + +if (failed) { + console.error('\nFAIL: one or more checks failed.'); + process.exit(1); +} +console.log('\nPASS'); diff --git a/tests/e2e/verify-wasmplayer-autoscroll-frame-load-race.mjs b/tests/e2e/verify-wasmplayer-autoscroll-frame-load-race.mjs new file mode 100644 index 000000000..dd7a311b8 --- /dev/null +++ b/tests/e2e/verify-wasmplayer-autoscroll-frame-load-race.mjs @@ -0,0 +1,94 @@ +// Manual verification that growing the static picture frame (#gamePanel — +// SetFramePicture/JS.setPanelContents → setPanelHeight(), e.g. "The Shack") +// after a turn has already ended doesn't leave that turn's opening line +// hidden behind the frame. +// +// Root cause (found via this test): scrollToTurnStart() unconditionally +// called $("#txtCommand").focus() after positioning the scroll. Calling +// .focus() on an element that ISN'T already the focused one makes the +// browser scroll it into view natively — and since #txtCommand sits right +// after all of #divOutput, that silently jumped straight to the document's +// bottom, discarding every position calculated above it. #txtCommand is +// often not already focused in exactly this scenario: right after page +// load, on mobile before the on-screen keyboard has been tapped, or any +// time focus was lost to a click/selection elsewhere — which is why this +// was "more noticeable on a small [mobile] browser window". Fixed by +// focusing with {preventScroll: true} (focusCommandInput()). +// +// This test simulates a slow-loading picture directly (real network image +// timing isn't controllable/deterministic here): run a normal turn with the +// frame still at its natural (empty, ~0-height) size, let the turn boundary +// finish and settle as it normally would — including losing input focus, +// simulating a player who tapped/clicked elsewhere — then simulate the +// image's delayed onload firing afterwards by growing #gamePanel and calling +// window.setPanelHeight() directly, exactly what the real onload handler does. +// +// Requires the WasmPlayer dev server running locally: +// node ../../src/WasmPlayer/dev-server.mjs +import { chromium } from 'playwright'; + +const baseUrl = process.argv[2] || 'http://localhost:5175'; + +const browser = await chromium.launch(); +let failed = false; + +function check(label, condition) { + console.log(`${condition ? 'PASS' : 'FAIL'}: ${label}`); + if (!condition) failed = true; +} + +const page = await browser.newPage({ viewport: { width: 500, height: 500 } }); +page.on('pageerror', err => console.log('[pageerror]', err.message)); + +await page.goto(`${baseUrl}/?url=/e2e-fixtures/scroll-walkthrough-test.aslx`); +await page.waitForSelector('#txtCommand', { state: 'visible', timeout: 30000 }); +await page.waitForTimeout(300); + +console.log('typing shout1 (frame still empty/0-height, as it would be before a real image loads)'); +await page.fill('#txtCommand', 'shout1'); +await page.press('#txtCommand', 'Enter'); +await page.waitForFunction(() => document.querySelector('#divOutput')?.textContent.includes('Turn 1, filler line 60'), { timeout: 15000 }); +// Let the turn-boundary scroll settle, exactly as it would in real play. +await page.waitForTimeout(1000); + +const turnmark1BeforeGrow = await page.$eval('#turnmark1', el => el.getBoundingClientRect()); +console.log(' #turnmark1 rect before frame grows:', JSON.stringify(turnmark1BeforeGrow)); + +// Blur the input first — simulating a player who tapped/clicked elsewhere +// (or a mobile session where it was never focused in the first place), the +// exact condition that exposed the .focus()-triggered scroll hijack. +await page.evaluate(() => document.getElementById('txtCommand')?.blur()); + +console.log('simulating the picture finishing loading well after the turn ended (growing #gamePanel + calling window.setPanelHeight())'); +await page.evaluate(() => { + const panel = document.getElementById('gamePanel'); + panel.innerHTML = 'FRAME'; + panel.style.cssText += ';display:block;height:150px;background:red;'; + window.setPanelHeight(); +}); + +// setPanelHeight() itself waits 100ms before re-scrolling; give the +// (possibly up-to-2s) resulting animation time to settle too. +await page.waitForTimeout(2500); + +const frameRect = await page.$eval('#gamePanel', el => el.getBoundingClientRect()); +const turnmark1Rect = await page.$eval('#turnmark1', el => el.getBoundingClientRect()); +console.log(' #gamePanel rect after growing:', JSON.stringify(frameRect)); +console.log(' #turnmark1 rect after re-scroll:', JSON.stringify(turnmark1Rect)); + +check( + 'Turn 1 start is still on screen after the frame grows', + turnmark1Rect.top >= 0 && turnmark1Rect.top < 500 +); +check( + 'Turn 1 start is not left hidden behind the now-fully-sized picture frame', + turnmark1Rect.top >= frameRect.bottom +); + +await browser.close(); + +if (failed) { + console.error('\nFAIL: one or more checks failed.'); + process.exit(1); +} +console.log('\nPASS'); diff --git a/tests/e2e/verify-wasmplayer-autoscroll-frame.mjs b/tests/e2e/verify-wasmplayer-autoscroll-frame.mjs new file mode 100644 index 000000000..6cb23135a --- /dev/null +++ b/tests/e2e/verify-wasmplayer-autoscroll-frame.mjs @@ -0,0 +1,98 @@ +// Manual verification that scrollToEnd()'s "don't scroll the start of new +// output off the top" target accounts for the static picture frame feature +// (#gamePanel — position:sticky, shown via SetFramePicture/JS.setPanelContents, +// e.g. used by "The Shack") in addition to the #qv-status toolbar. Before +// this fix, stickyOverlayHeight() only looked at #qv-status, so a visible +// picture frame would sit on top of (hide) the very turn-start line the +// scroll was supposed to reveal. +// +// Fixture: tests/e2e/fixtures/scroll-frame-test.aslx defines "showframe" +// (sets a 150px-tall #gamePanel via JS.setPanelContents) and shout1/shout2 +// (long filler output with a at the very start), +// wired into a "withframe" walkthrough that shows the frame then runs both +// shouts. +// +// Requires the WasmPlayer dev server running locally: +// node ../../src/WasmPlayer/dev-server.mjs +import { chromium } from 'playwright'; + +const baseUrl = process.argv[2] || 'http://localhost:5175'; + +const browser = await chromium.launch(); +let failed = false; + +function check(label, condition) { + console.log(`${condition ? 'PASS' : 'FAIL'}: ${label}`); + if (!condition) failed = true; +} + +const context = await browser.newContext({ viewport: { width: 1024, height: 768 } }); +const previewPage = await context.newPage(); +previewPage.on('pageerror', err => console.log('[pageerror]', err.message)); + +const senderPage = await context.newPage(); +const fixtureUrl = `${baseUrl}/e2e-fixtures/scroll-frame-test.aslx`; +await senderPage.goto(baseUrl); + +const handoffDone = senderPage.evaluate(async (url) => { + const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer()); + await new Promise((resolve) => { + const bc = new BroadcastChannel('quest-preview'); + bc.onmessage = ({ data }) => { + if (data.type === 'ready') { + bc.postMessage({ type: 'game', bytes, filename: 'scroll-frame-test.aslx' }); + resolve(); + } + }; + }); +}, fixtureUrl); + +await previewPage.goto(`${baseUrl}/?source=editor`); +await handoffDone; + +await previewPage.waitForSelector('#txtCommand', { state: 'visible', timeout: 30000 }); +await previewPage.waitForTimeout(300); + +await previewPage.click('#cmdDebug'); +await previewPage.waitForSelector('#questVivaDebugger[open]', { timeout: 5000 }); +await previewPage.click('#qv-debugger-tabs button:text("Walkthrough")'); +await previewPage.click('#qv-debugger-list [data-item="withframe"]'); +await previewPage.waitForSelector('[data-run-walkthrough="withframe"]'); +await previewPage.click('[data-run-walkthrough="withframe"]'); + +await previewPage.waitForFunction(() => { + const el = document.querySelector('[data-walkthrough-status]'); + return el && el.textContent && el.textContent !== 'Running…'; +}, { timeout: 15000 }); +const status = await previewPage.$eval('[data-walkthrough-status]', el => el.textContent); +check('Walkthrough runs to completion', status === 'Done'); + +await previewPage.click('#qv-debugger-close'); + +// Let scroll animations settle. +await previewPage.waitForTimeout(2500); + +const frameVisible = await previewPage.$eval('#gamePanel', el => getComputedStyle(el).display !== 'none'); +check('#gamePanel (picture frame) is showing', frameVisible); + +const frameRect = await previewPage.$eval('#gamePanel', el => el.getBoundingClientRect()); +const turn2Rect = await previewPage.$eval('#turnmark2', el => el.getBoundingClientRect()); +console.log(' #gamePanel rect:', JSON.stringify(frameRect)); +console.log(' #turnmark2 rect:', JSON.stringify(turn2Rect)); + +check( + 'Final turn start (#turnmark2) is visible in the viewport at all', + turn2Rect.top >= 0 && turn2Rect.top < 768 +); +check( + 'Final turn start is not hidden behind the sticky picture frame', + turn2Rect.top >= frameRect.bottom +); + +await browser.close(); + +if (failed) { + console.error('\nFAIL: one or more checks failed.'); + process.exit(1); +} +console.log('\nPASS'); diff --git a/tests/e2e/verify-wasmplayer-autoscroll.mjs b/tests/e2e/verify-wasmplayer-autoscroll.mjs new file mode 100644 index 000000000..39bdb85da --- /dev/null +++ b/tests/e2e/verify-wasmplayer-autoscroll.mjs @@ -0,0 +1,137 @@ +// Manual verification for auto-scroll behaviour in the game output panel +// (playercore.js's scrollToEnd()/markScrollPosition()). Bug report: on a +// turn producing a long stream of output, the page should scroll just far +// enough that the *start* of that turn's output stays on screen, not jump +// straight to the document's bottom (which scrolls the beginning of the new +// text off the top). scrollToEnd() used to always target +// document.body.scrollHeight unconditionally, and queued a fresh jQuery +// .animate() on every OutputText call without .stop()'ing the previous one +// first — on a walkthrough firing several such turns back-to-back with no +// delay, that produced exactly the reported "scrolls immediately, then +// stalls, then slowly catches up" symptom (a growing backlog of queued +// animations targeting stale, already-passed scroll positions). +// +// Fixture: tests/e2e/fixtures/scroll-walkthrough-test.aslx defines six +// commands (shout1..shout6), each printing ~30 lines of filler text (well +// over one screenful) preceded by a marker at the very +// start of that turn's output. The "rapid" walkthrough fires all six with no +// delay between them — the exact repro shape (WasmPlayer's debugger +// walkthrough runner never disables animation, unlike WebPlayer's). +// +// Requires the WasmPlayer dev server running locally: +// node ../../src/WasmPlayer/dev-server.mjs +import { chromium } from 'playwright'; + +const baseUrl = process.argv[2] || 'http://localhost:5175'; + +const browser = await chromium.launch(); +let failed = false; + +function check(label, condition) { + console.log(`${condition ? 'PASS' : 'FAIL'}: ${label}`); + if (!condition) failed = true; +} + +const context = await browser.newContext({ viewport: { width: 1024, height: 768 } }); +const previewPage = await context.newPage(); +previewPage.on('pageerror', err => console.log('[pageerror]', err.message)); +previewPage.on('console', msg => console.log(`[console.${msg.type()}]`, msg.text())); + +const senderPage = await context.newPage(); +const fixtureUrl = `${baseUrl}/e2e-fixtures/scroll-walkthrough-test.aslx`; +await senderPage.goto(baseUrl); + +const handoffDone = senderPage.evaluate(async (url) => { + const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer()); + await new Promise((resolve) => { + const bc = new BroadcastChannel('quest-preview'); + bc.onmessage = ({ data }) => { + if (data.type === 'ready') { + bc.postMessage({ type: 'game', bytes, filename: 'scroll-walkthrough-test.aslx' }); + resolve(); + } + }; + }); +}, fixtureUrl); + +await previewPage.goto(`${baseUrl}/?source=editor`); +await handoffDone; + +await previewPage.waitForSelector('#txtCommand', { state: 'visible', timeout: 30000 }); +await previewPage.waitForTimeout(300); + +// Sample document.scrollingElement.scrollTop throughout the run so we can +// see the shape of the scroll over time, not just the end state. +await previewPage.evaluate(() => { + window.__scrollSamples = []; + window.__scrollSampleTimer = setInterval(() => { + window.__scrollSamples.push(document.scrollingElement.scrollTop); + }, 50); +}); + +console.log('step: click debug'); +await previewPage.click('#cmdDebug', { timeout: 10000 }); +console.log('step: wait dialog open'); +await previewPage.waitForSelector('#questVivaDebugger[open]', { timeout: 5000 }); +console.log('step: click walkthrough tab'); +await previewPage.click('#qv-debugger-tabs button:text("Walkthrough")', { timeout: 10000 }); +console.log('step: select rapid'); +await previewPage.click('#qv-debugger-list [data-item="rapid"]', { timeout: 10000 }); +console.log('step: wait run button'); +await previewPage.waitForSelector('[data-run-walkthrough="rapid"]', { timeout: 10000 }); +console.log('step: click run'); +await previewPage.click('[data-run-walkthrough="rapid"]', { timeout: 10000 }); + +console.log('step: wait for Done'); +await previewPage.waitForFunction(() => { + const el = document.querySelector('[data-walkthrough-status]'); + return el && el.textContent && el.textContent !== 'Running…'; +}, { timeout: 15000 }); +console.log('step: got status'); +const status = await previewPage.$eval('[data-walkthrough-status]', el => el.textContent); +check('Walkthrough runs to completion', status === 'Done'); + +await previewPage.click('#qv-debugger-close'); + +// Let any trailing scroll animation settle (scrollToEnd()'s animation duration +// is capped at 2000ms), then stop sampling. +await previewPage.waitForTimeout(2500); +const samples = await previewPage.evaluate(() => { + clearInterval(window.__scrollSampleTimer); + return window.__scrollSamples; +}); +console.log(' scrollTop samples over time:', samples.join(', ')); + +// ── The core assertion: the last turn's marker should still be near the top +// of the viewport, not scrolled past (off the top) or left far below (i.e. +// the fix should have scrolled *to* it, not *past* it to the document end). +const turn6Rect = await previewPage.$eval('#turnmark6', el => el.getBoundingClientRect()); +console.log(' #turnmark6 rect:', JSON.stringify(turn6Rect)); +check( + 'Start of the final turn (#turnmark6) is visible, not scrolled off the top', + turn6Rect.top >= 0 && turn6Rect.top < 768 +); + +const finalScrollTop = await previewPage.evaluate(() => document.scrollingElement.scrollTop); +const maxScrollTop = await previewPage.evaluate(() => document.scrollingElement.scrollHeight - window.innerHeight); +console.log(` finalScrollTop=${finalScrollTop} maxScrollTop=${maxScrollTop}`); +check( + 'Final scroll position stops well short of the true document bottom (does not overshoot past the last turn\'s start)', + finalScrollTop < maxScrollTop - 200 +); + +// ── No pathological backlog: once scrolling reaches its final position it +// should stay flat, not keep creeping upward/downward well after the +// walkthrough itself already reported Done (a queued-animation backlog +// draining after the fact). +const tailSamples = samples.slice(-6); +const tailSpread = Math.max(...tailSamples) - Math.min(...tailSamples); +check('Scroll position is settled (not still drifting) by the time sampling stops', tailSpread < 5); + +await browser.close(); + +if (failed) { + console.error('\nFAIL: one or more checks failed.'); + process.exit(1); +} +console.log('\nPASS');