mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
fix(terminal): one touch contact is one paging gesture; bound slow ticks and wobble
Review of #527 (Ark0N, 2026-10-11): 1. A slow touch drag paged a full screen per line. touchmove calls the fallback once per whole cell, so a slow drag's calls were further apart than the 150 ms gesture gap and each pre-paid a page (12 lines at 170 ms sent 12 PageUps). The touch paths now tag every call, touchmove and momentum frames alike, with a per-contact touchId (incremented on touchstart), and pageKeysForGesture never splits a contact by time; a new contact is a new gesture. 2. A pre-paid debt now survives up to PAGE_KEY_DEBT_MS (1 s) of quiet when the next event goes the same way, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay it back instead of each pre-paying. A reversal inside the gap (or inside one contact) drops the debt and accumulates without pre-paying, so a +/-3 px wobble no longer pages back and forth. A reversal after the gap still pages at once. Main pane and tiles share the helper, so both get it. 3. A wheel consumed by the dialog check now logs page-keys-dialog-open. 4. The Dashboard wiki names newer Codex beside OpenCode; the invariants paragraph describes the new gesture boundaries. Tests: unit regressions through the real _maybePageCliTranscript, helper cases, tile/main parity for ticks and wobble, and a Chromium test that drives the real initTerminal touch listeners (12 PageUps before, 1 after).
This commit is contained in:
1 parent
eaf924a291
commit
3ea1e03aaf
7 files changed
+426
-32
No files matched your search
@@ -35,6 +35,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
'test/split-pane-terminal.browser.test.ts',
|
||||
'test/terminal-tile-scroll.browser.test.ts',
|
||||
'test/terminal-touch-paging.browser.test.ts',
|
||||
'test/shift-enter-keypress.browser.test.ts',
|
||||
'test/key-tester.browser.test.ts',
|
||||
'test/webhook-settings.browser.test.ts',
|
||||
|
||||
@@ -225,7 +225,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
||||
|
||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still); a >150 ms pause, a direction change or a tab switch starts a new gesture. Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ These gesture rules (first-event page, notch accumulation, prepaid-debt reset, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's ownLine truncated
|
||||
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screeLine truncated
|
||||
|
||||
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
|
||||
|
||||
|
||||
@@ -223,8 +223,12 @@ Worth knowing:
|
||||
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
|
||||
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
|
||||
always local scrollback. OpenCode's wheel and swipes page its own conversation
|
||||
(PageUp/PageDown); in a grid tile or the split view's second pane the wheel does
|
||||
too. Other CLIs scroll locally.
|
||||
(PageUp/PageDown), and so do newer Codex versions, which draw on the alternate screen
|
||||
and leave the terminal no history of its own (an older Codex that draws inline keeps
|
||||
its history in the terminal and scrolls locally). The first small movement of a
|
||||
gesture pages at once, then one page per half screen of travel; one finger on the
|
||||
screen is one gesture however slowly it moves. In a grid tile or the split view's
|
||||
second pane the wheel pages too. Other CLIs scroll locally.
|
||||
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
||||
`Ctrl+Shift+C` always copies.
|
||||
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
|
||||
|
||||
@@ -102,6 +102,10 @@
|
||||
// 4 rows) accumulates toward half a screen as before; paging at once on it made
|
||||
// slow notches, each its own gesture past the gap, page a full screen apiece.
|
||||
const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2;
|
||||
// A pre-paid page's debt outlives the gesture gap for this long when the next
|
||||
// event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a
|
||||
// Firefox 1-line notch) pay it back instead of each pre-paying a page of its own.
|
||||
const PAGE_KEY_DEBT_MS = 1000;
|
||||
|
||||
// Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The
|
||||
// body of the primary pane's _wheelScrollLinesFloat (see its comment for the
|
||||
@@ -170,12 +174,21 @@
|
||||
// - a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel) and a mostly
|
||||
// horizontal swipe (both deltas present, |deltaX| > |deltaY|) send nothing
|
||||
// and leave the state alone (the caller still consumes them);
|
||||
// - after PAGE_KEY_GESTURE_GAP_MS of quiet, or a direction change, a NEW
|
||||
// gesture starts, and a trackpad-sized opening event (at least
|
||||
// - a NEW gesture starts on the first event, on a different touch contact
|
||||
// (`ev.touchId`, set by the touch paths per touchstart and carried by that
|
||||
// contact's momentum; a wheel event has none, so wheel and touch never
|
||||
// share a gesture), or, for events outside any contact, after quiet:
|
||||
// PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS while a pre-paid
|
||||
// page is still owed in the same direction. One contact is never split by
|
||||
// time, so a slow drag (one line per 170 ms) is one gesture;
|
||||
// - a new gesture's trackpad-sized opening event (at least
|
||||
// PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) pages AT ONCE,
|
||||
// pre-paid: the half screen it skipped is owed back by the rest of the
|
||||
// gesture, so the rate stays one page per half screen. A debt left by a
|
||||
// pre-paid page dies with its gesture;
|
||||
// - a direction change inside a gesture (a reversal within the gap, or
|
||||
// within the same contact) drops a pre-paid debt and accumulates, never
|
||||
// pre-paying, so a +/-3 px trackpad wobble cannot page back and forth;
|
||||
// - everything else, a wheel notch included, accumulates through
|
||||
// pageKeysForTravel, carried across notches.
|
||||
// Returns { state, keys } with keys '' when nothing is due.
|
||||
@@ -187,24 +200,37 @@
|
||||
if (dx && dy && dx > dy) return { state: prev, keys: '' };
|
||||
const perPage = pageKeyRows(rows);
|
||||
const dir = lines < 0 ? -1 : 1;
|
||||
const touchId = typeof ev?.touchId === 'number' ? ev.touchId : null;
|
||||
const prevTouchId = typeof prev.touchId === 'number' ? prev.touchId : null;
|
||||
let pending = prev.pending || 0;
|
||||
let prepaid = !!prev.prepaid;
|
||||
const gestureStart =
|
||||
typeof prev.lastAt !== 'number' || now - prev.lastAt > PAGE_KEY_GESTURE_GAP_MS || dir !== prev.dir;
|
||||
if (gestureStart) {
|
||||
const reversed = dir !== prev.dir;
|
||||
let gestureStart;
|
||||
if (typeof prev.lastAt !== 'number' || touchId !== prevTouchId) {
|
||||
gestureStart = true;
|
||||
} else if (touchId !== null) {
|
||||
gestureStart = false; // the same contact, however long it has been
|
||||
} else {
|
||||
const quiet = now - prev.lastAt;
|
||||
gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS);
|
||||
}
|
||||
if (gestureStart || reversed) {
|
||||
// A pre-paid debt belongs to its gesture and its direction.
|
||||
if (prepaid) pending = 0;
|
||||
prepaid = false;
|
||||
}
|
||||
if (gestureStart) {
|
||||
const size = Math.abs(lines);
|
||||
if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) {
|
||||
// Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign).
|
||||
return {
|
||||
state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true },
|
||||
state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true, touchId },
|
||||
keys: dir < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN,
|
||||
};
|
||||
}
|
||||
}
|
||||
const step = pageKeysForTravel(pending, lines, rows);
|
||||
return { state: { pending: step.pending, lastAt: now, dir, prepaid }, keys: step.keys };
|
||||
return { state: { pending: step.pending, lastAt: now, dir, prepaid, touchId }, keys: step.keys };
|
||||
}
|
||||
|
||||
const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;
|
||||
@@ -366,6 +392,7 @@
|
||||
PAGE_KEY_GESTURE_GAP_MS,
|
||||
PAGE_KEY_MIN_START_ROWS,
|
||||
PAGE_KEY_IMMEDIATE_MAX_ROWS,
|
||||
PAGE_KEY_DEBT_MS,
|
||||
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
|
||||
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
|
||||
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
|
||||
@@ -1119,6 +1146,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
let lastTime = 0;
|
||||
let scrollFrame = null;
|
||||
let isTouching = false;
|
||||
// One id per touch contact (touchstart), carried by its touchmoves AND its
|
||||
// momentum frames, so the page-key fallback treats a whole contact as one
|
||||
// gesture however slowly the finger moves (pageKeysForGesture).
|
||||
let touchContactId = 0;
|
||||
|
||||
const scrollLoop = (timestamp) => {
|
||||
const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1;
|
||||
@@ -1132,7 +1163,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Flick momentum keeps feeding the CLI's transcript from the last
|
||||
// touch point; the 40ms coalescer batches the per-frame reports.
|
||||
this._forwardScrollToApp(touchLastX, touchLastY, lines);
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) {
|
||||
this.terminal.scrollLines(lines);
|
||||
this._maybeLoadMoreHistoryOnScroll(lines);
|
||||
}
|
||||
@@ -1205,6 +1236,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
touchContactId++;
|
||||
didScroll = false;
|
||||
tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused();
|
||||
// Classifying scans the whole viewport with translateToString, and
|
||||
@@ -1309,7 +1341,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this._shouldForwardWheelToApp({ shiftKey: false })) {
|
||||
this._logScrollRouting('forward-sgr');
|
||||
this._forwardScrollToApp(touchLastX, touchLastY, lines);
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) {
|
||||
this._logScrollRouting('local-scrollback');
|
||||
this._noteTerminalUserScroll(lines);
|
||||
this.terminal.scrollLines(lines);
|
||||
@@ -5840,9 +5872,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
* travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick
|
||||
* never sent a key at all. That page is pre-paid: the travel it skipped is
|
||||
* owed back by the rest of the gesture, so the rate stays one page per
|
||||
* `perPage` rows. A pause (PAGE_KEY_GESTURE_GAP_MS), a direction change or a
|
||||
* tab switch starts a new gesture. A larger opening event is a wheel notch and
|
||||
* accumulates as before, so slow notches still page once per `perPage` rows.
|
||||
* `perPage` rows. A new touch contact (`ev.touchId`), a pause outside a contact
|
||||
* (PAGE_KEY_GESTURE_GAP_MS, or PAGE_KEY_DEBT_MS while a same-direction page is
|
||||
* still owed), a reversal after the gap or a tab switch starts a new gesture;
|
||||
* one contact is never split by time, and a quick reversal never pre-pays. A
|
||||
* larger opening event is a wheel notch and accumulates as before, so slow
|
||||
* notches still page once per `perPage` rows.
|
||||
*
|
||||
* Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad
|
||||
* pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|;
|
||||
@@ -5868,7 +5903,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!this._localScrollbackIsHollow()) return false;
|
||||
// An open dialog in THIS pane's session (the active one): a page key would
|
||||
// move its selector. Consumed, so the buffer does not scroll under it either.
|
||||
if (this.tabAlerts?.get(this.activeSessionId) === 'action') return true;
|
||||
if (this.tabAlerts?.get(this.activeSessionId) === 'action') {
|
||||
this._logScrollRouting('page-keys-dialog-open');
|
||||
return true;
|
||||
}
|
||||
// Leftover travel and the gesture in progress belong to the tab they were made on.
|
||||
if (this._pageKeySession !== this.activeSessionId) {
|
||||
this._pageKeySession = this.activeSessionId;
|
||||
|
||||
@@ -202,12 +202,14 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
||||
clock.now = () => now;
|
||||
|
||||
app._maybePageCliTranscript({ shiftKey: false }, -1); // first event: one PageUp
|
||||
app._maybePageCliTranscript({ shiftKey: false }, 1); // reversal: one PageDown at once
|
||||
now += 1_000;
|
||||
app._maybePageCliTranscript({ shiftKey: false }, 1); // after a pause: another at once
|
||||
app._maybePageCliTranscript({ shiftKey: false }, 1); // reversal inside the gap: accumulates, no page
|
||||
now += 200;
|
||||
app._maybePageCliTranscript({ shiftKey: false }, -1); // a reversal after the gap: a PageUp at once
|
||||
now += 1_100;
|
||||
app._maybePageCliTranscript({ shiftKey: false }, 1); // after a pause: a PageDown at once
|
||||
app._maybePageCliTranscript({ shiftKey: false }, 0.05); // sub-row jitter in the same gesture: nothing
|
||||
app._flushWheelSgrQueue();
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~\x1b[6~\x1b[6~' }]);
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~\x1b[5~\x1b[6~' }]);
|
||||
});
|
||||
|
||||
it('lets wheel notches accumulate instead of paging a full screen on each one', () => {
|
||||
@@ -422,7 +424,145 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
||||
// Wheel: after the forwarding gate, before the local smooth scroll.
|
||||
expect(source).toContain('if (this._maybePageCliTranscript(ev, lines)) return;');
|
||||
// Touch: touchmove and the momentum loop both fall through to it.
|
||||
expect(source.match(/else if \(!this\._maybePageCliTranscript\(\{ shiftKey: false \}, lines\)\)/g)).toHaveLength(2);
|
||||
expect(
|
||||
source.match(
|
||||
/else if \(!this\._maybePageCliTranscript\(\{ shiftKey: false, touchId: touchContactId \}, lines\)\)/g
|
||||
)
|
||||
).toHaveLength(2);
|
||||
// ...and every contact gets its own id at touchstart, so a contact is one gesture.
|
||||
expect(source).toContain('touchContactId++;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('page-key gestures across slow touch drags, slow wheel ticks and jitter (#527 review)', () => {
|
||||
const UP = '\x1b[5~';
|
||||
const DOWN = '\x1b[6~';
|
||||
|
||||
/** A hollow 38-row pane (19 rows a page) on a hand-moved clock, with every key sent. */
|
||||
function pane(mode = 'codex') {
|
||||
const h = hollowApp({ mode, rows: 38 });
|
||||
let now = 10_000;
|
||||
h.clock.now = () => now;
|
||||
const keys = () => {
|
||||
h.app._flushWheelSgrQueue();
|
||||
return h.sent.map((s) => s.data).join('');
|
||||
};
|
||||
return { ...h, keys, advance: (ms: number) => (now += ms) };
|
||||
}
|
||||
const count = (keys: string, key: string) => keys.split(key).length - 1;
|
||||
|
||||
it('keeps one touch contact one gesture, however slowly the finger moves', () => {
|
||||
const p = pane();
|
||||
// touchmove calls once per whole cell, so a slow drag arrives as single lines
|
||||
// 170 ms apart, each past the 150 ms gesture gap. It sent one PageUp per line.
|
||||
for (let i = 0; i < 12; i++) {
|
||||
expect(p.app._maybePageCliTranscript({ shiftKey: false, touchId: 1 }, -1)).toBe(true);
|
||||
p.advance(170);
|
||||
}
|
||||
expect(p.keys()).toBe(UP);
|
||||
|
||||
// A new contact is a new gesture: its first line pages at once.
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 2 }, -1);
|
||||
expect(p.keys()).toBe(UP + UP);
|
||||
});
|
||||
|
||||
it('pages a slow 40-line drag twice, and keeps momentum in the same gesture', () => {
|
||||
const p = pane();
|
||||
for (let i = 0; i < 40; i++) {
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 7 }, -1);
|
||||
p.advance(170);
|
||||
}
|
||||
// 1 at once, then 18 owed back, then 19 more: two pages by line 38.
|
||||
expect(count(p.keys(), UP)).toBe(2);
|
||||
|
||||
// Momentum frames after the finger lifts carry the same contact: they
|
||||
// continue the drag's travel instead of opening a gesture of their own.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 7 }, -1);
|
||||
p.advance(16);
|
||||
}
|
||||
expect(count(p.keys(), UP)).toBe(2);
|
||||
});
|
||||
|
||||
it('never splits a contact on a pause, but does not carry its debt into the next contact', () => {
|
||||
const p = pane();
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 3 }, -1); // pages, owes 18
|
||||
p.advance(5_000); // the finger rests, still down
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 3 }, -1);
|
||||
expect(p.keys()).toBe(UP);
|
||||
p.advance(10);
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 4 }, -1); // a new contact pages at once
|
||||
expect(p.keys()).toBe(UP + UP);
|
||||
});
|
||||
|
||||
it('pages slow small wheel ticks once per half screen, not once per tick', () => {
|
||||
// Five 40 px ticks 250 ms apart (1.6 rows each, under the 2-row trackpad
|
||||
// size) used to page once per tick. The first one still pages at once.
|
||||
const p = pane();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40, deltaMode: 0 }, -1.6);
|
||||
p.advance(250);
|
||||
}
|
||||
expect(p.keys()).toBe(UP);
|
||||
|
||||
// Firefox reports a notch as 1 line (deltaMode 1): the same.
|
||||
const f = pane();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
f.app._maybePageCliTranscript({ shiftKey: false, deltaY: -1, deltaMode: 1 }, -1);
|
||||
f.advance(250);
|
||||
}
|
||||
expect(f.keys()).toBe(UP);
|
||||
});
|
||||
|
||||
it('lets a pre-paid debt lapse after a second of quiet, so the next tick pages at once again', () => {
|
||||
const p = pane();
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40 }, -1.6);
|
||||
p.advance(900);
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40 }, -1.6); // inside the second: owed
|
||||
expect(p.keys()).toBe(UP);
|
||||
p.advance(1_100);
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40 }, -1.6); // debt lapsed: pages
|
||||
expect(p.keys()).toBe(UP + UP);
|
||||
});
|
||||
|
||||
it('does not page back and forth on a +/-3 px trackpad wobble', () => {
|
||||
const p = pane();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const dy = i % 2 ? 3 : -3;
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: dy }, dy / 25);
|
||||
p.advance(16);
|
||||
}
|
||||
// The opening event is a fresh gesture and pages; no reversal inside the
|
||||
// gap pre-pays, so the wobble sends nothing after it.
|
||||
expect(p.keys()).toBe(UP);
|
||||
});
|
||||
|
||||
it('still pages a deliberate reversal at once after a pause', () => {
|
||||
const p = pane();
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -25 }, -1);
|
||||
p.advance(200);
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1);
|
||||
expect(p.keys()).toBe(UP + DOWN);
|
||||
});
|
||||
|
||||
it('does not let a reversal inherit the other direction’s debt', () => {
|
||||
const p = pane();
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -25 }, -1); // UP, owes 18 upward
|
||||
p.advance(16);
|
||||
// Downward travel just short of a page: the upward debt must not count toward it.
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 * 18 }, 18);
|
||||
expect(p.keys()).toBe(UP);
|
||||
p.advance(16);
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1);
|
||||
expect(p.keys()).toBe(UP + DOWN);
|
||||
});
|
||||
|
||||
it('logs a wheel the dialog check consumed', () => {
|
||||
const p = pane();
|
||||
p.app.tabAlerts = new Map([['sess-1', 'action']]);
|
||||
expect(p.app._maybePageCliTranscript({ shiftKey: false }, -1)).toBe(true);
|
||||
expect(p.keys()).toBe('');
|
||||
expect(p.logs.some((line) => line.includes('[scroll] sess-1 → page-keys-dialog-open'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -464,24 +604,40 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => {
|
||||
expect(steps.map((s) => s.keys).join('')).toBe(UP);
|
||||
});
|
||||
|
||||
it('drops a pre-paid debt when its gesture ends', () => {
|
||||
// A flick pre-pays a page; a second flick after a pause pages at once again
|
||||
// instead of first paying off the first one's debt.
|
||||
it('keeps a same-direction pre-paid debt for PAGE_KEY_DEBT_MS, then drops it with its gesture', () => {
|
||||
const { PAGE_KEY_DEBT_MS } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
|
||||
expect(PAGE_KEY_DEBT_MS).toBe(1000);
|
||||
// A tick 500 ms later, past the 150 ms gap, pays the debt back instead of
|
||||
// pre-paying a second page; a flick after a second of quiet pages at once.
|
||||
const steps = run([
|
||||
[-1, 0],
|
||||
[-1, 500],
|
||||
[-1, 1600],
|
||||
]);
|
||||
expect(steps.map((s) => s.keys)).toEqual([UP, UP]);
|
||||
expect(steps.map((s) => s.keys)).toEqual([UP, '', UP]);
|
||||
});
|
||||
|
||||
it('starts a new gesture on a direction change, and ignores sub-row jitter', () => {
|
||||
it('pre-pays a reversal only after the gap, and ignores sub-row jitter', () => {
|
||||
const steps = run([
|
||||
[-1, 0],
|
||||
[1, 10],
|
||||
[0.05, 20],
|
||||
[-0.05, 1000],
|
||||
[1, 10], // reversal inside the gap: accumulates, no page
|
||||
[-1, 20], // and back: still nothing
|
||||
[1, 300], // a reversal after the gap is a new gesture: pages at once
|
||||
[0.05, 310],
|
||||
[-0.05, 1500],
|
||||
]);
|
||||
expect(steps.map((s) => s.keys)).toEqual([UP, DOWN, '', '']);
|
||||
expect(steps.map((s) => s.keys)).toEqual([UP, '', '', DOWN, '', '']);
|
||||
});
|
||||
|
||||
it('treats one touch contact as one gesture, and a new contact as a new one', () => {
|
||||
const steps = run([
|
||||
[-1, 0, { touchId: 1 }],
|
||||
[-1, 5_000, { touchId: 1 }], // same contact, any pause: owed
|
||||
[1, 5_010, { touchId: 1 }], // a reversal within the contact never pre-pays
|
||||
[-1, 5_020, { touchId: 2 }], // a new contact pages at once
|
||||
[-1, 5_030], // a wheel event is not that contact: a new gesture
|
||||
]);
|
||||
expect(steps.map((s) => s.keys)).toEqual([UP, '', '', UP, UP]);
|
||||
});
|
||||
|
||||
it('sends nothing for a pinch or a mostly horizontal swipe, and leaves the state as it was', () => {
|
||||
@@ -525,7 +681,7 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => {
|
||||
[-1, {}],
|
||||
[-10, {}],
|
||||
[-30, {}],
|
||||
[1, {}],
|
||||
[20, {}], // a reversal inside the gap pages by travel, not at once
|
||||
[-5, { ctrlKey: true }],
|
||||
[-5, { deltaX: 400, deltaY: -125 }],
|
||||
[-1000, {}],
|
||||
|
||||
@@ -588,7 +588,7 @@ describe('page-key travel, cap and coalescing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('gesture handling is the primary pane\'s, through the shared pure helper', () => {
|
||||
describe("gesture handling is the primary pane's, through the shared pure helper", () => {
|
||||
beforeEach(() => {
|
||||
FakeFit.proposed = { cols: 80, rows: 36 }; // half a screen: 18 rows a page
|
||||
});
|
||||
@@ -710,6 +710,54 @@ describe('gesture handling is the primary pane\'s, through the shared pure helpe
|
||||
expect(tileBytes).toContain(PAGE_DOWN);
|
||||
expect(tileBytes).toBe(sent.join(''));
|
||||
});
|
||||
|
||||
it('pages slow small ticks and a back-and-forth wobble once, exactly as the primary pane does', async () => {
|
||||
const advance = manualClock();
|
||||
// Five 40 px ticks 250 ms apart (each under the 2-row trackpad size), a pause
|
||||
// longer than the pre-paid debt's second, then a +/-3 px wobble at 16 ms.
|
||||
const steps: Array<[number, number]> = [
|
||||
...Array.from({ length: 5 }, () => [-40, 250] as [number, number]),
|
||||
[0, 1_200],
|
||||
...Array.from({ length: 12 }, (_, i) => [i % 2 ? 3 : -3, 16] as [number, number]),
|
||||
];
|
||||
const events = () => steps.filter(([px]) => px).map(([px]) => wheel(px));
|
||||
const gaps = steps.reduce<number[]>((acc, [px, gap]) => {
|
||||
if (px) acc.push(gap);
|
||||
else acc[acc.length - 1] += gap;
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' });
|
||||
events().forEach((ev, i) => {
|
||||
mount.fire('wheel', ev);
|
||||
advance(gaps[i]);
|
||||
});
|
||||
const tileBytes = flushed(ws)
|
||||
.map((f) => f.d)
|
||||
.join('');
|
||||
// One page for the slow ticks, one for the wobble's opening event, nothing more.
|
||||
expect(tileBytes).toBe(PAGE_UP + PAGE_UP);
|
||||
|
||||
const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & {
|
||||
terminal: unknown;
|
||||
_maybePageCliTranscript(ev: unknown, lines: number): boolean;
|
||||
_wheelScrollLinesFloat(ev: unknown): number;
|
||||
_flushWheelSgrQueue(): void;
|
||||
};
|
||||
const sent: string[] = [];
|
||||
primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
|
||||
primary.terminal = {
|
||||
rows: 36,
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
buffer: { active: { type: 'normal', baseY: 0, viewportY: 0 } },
|
||||
};
|
||||
events().forEach((ev, i) => {
|
||||
primary._maybePageCliTranscript(ev, primary._wheelScrollLinesFloat(ev));
|
||||
advance(gaps[i]);
|
||||
});
|
||||
primary._flushWheelSgrQueue();
|
||||
expect(sent.join('')).toBe(tileBytes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the dialog check reads the TILE's session, never the active tab's", () => {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @fileoverview Real Chromium coverage for the page-key fallback on the TOUCH
|
||||
* path of the primary pane (#527 review, #508): the touch listeners that
|
||||
* `initTerminal()` installs on `#terminalContainer`, not a call into the pure
|
||||
* helper.
|
||||
*
|
||||
* A slow drag reaches `_maybePageCliTranscript` once per whole cell of finger
|
||||
* travel, so its calls are further apart than the 150 ms gesture gap. Each call
|
||||
* used to start a new gesture and pre-pay a page, so twelve lines of slow
|
||||
* finger travel sent twelve PageUps. The touch paths now tag every call with
|
||||
* the contact's id (one per touchstart, carried by its momentum frames), and a
|
||||
* contact is one gesture.
|
||||
*
|
||||
* What runs for real: the shipped app (page served by a WebServer on port 0),
|
||||
* its xterm, the touch listeners, the momentum loop on requestAnimationFrame,
|
||||
* the gates in terminal-ui.js and the 40 ms key coalescer. What is stubbed:
|
||||
* the active session (a Codex session the page knows about but no PTY) and
|
||||
* `_sendInputEphemeral`, which records the keys instead of sending them. The
|
||||
* touch events are DOM events carrying `touches`, dispatched on the container
|
||||
* with real time between them.
|
||||
*
|
||||
* Port: ephemeral (`new WebServer(0, …)`, read back from `boundPort`).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PROBE_ID = 'touch-paging-probe';
|
||||
const UP = '\x1b[5~';
|
||||
|
||||
describe('touch paging of a hollow Codex pane in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1, hasTouch: true });
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
/** Dispatch one touch event of `type` at (x, y) on the terminal container. */
|
||||
const touch = (type: string, x: number, y: number) =>
|
||||
page.evaluate(
|
||||
([t, cx, cy]) => {
|
||||
const container = document.getElementById('terminalContainer')!;
|
||||
const ev = new Event(t as string, { bubbles: true, cancelable: true });
|
||||
const point = { clientX: cx, clientY: cy, target: container };
|
||||
Object.defineProperty(ev, 'touches', { value: t === 'touchend' ? [] : [point] });
|
||||
Object.defineProperty(ev, 'changedTouches', { value: [point] });
|
||||
container.dispatchEvent(ev);
|
||||
},
|
||||
[type, x, y] as const
|
||||
);
|
||||
|
||||
const probe = () =>
|
||||
page.evaluate(() => {
|
||||
const p = (window as any).__touchProbe;
|
||||
p.app._flushWheelSgrQueue();
|
||||
return { keys: p.sent.join(''), calls: p.calls.slice(), rows: p.app.terminal.rows };
|
||||
});
|
||||
|
||||
it('pages a slow one-line-at-a-time drag once, keeps momentum in it, and pages a new contact at once', async () => {
|
||||
const cell = await page.evaluate((id) => {
|
||||
const w = window as any;
|
||||
const app = w.app;
|
||||
app.sessions.set(id, { id, name: id, mode: 'codex', status: 'idle' });
|
||||
app.activeSessionId = id;
|
||||
app.tabAlerts?.delete?.(id);
|
||||
const sent: string[] = [];
|
||||
app._sendInputEphemeral = (_sessionId: string, data: string) => sent.push(data);
|
||||
const calls: Array<{ touchId: unknown; lines: number }> = [];
|
||||
const real = app._maybePageCliTranscript;
|
||||
app._maybePageCliTranscript = function (ev: { touchId?: unknown }, lines: number) {
|
||||
calls.push({ touchId: ev?.touchId, lines });
|
||||
return real.call(this, ev, lines);
|
||||
};
|
||||
w.__touchProbe = { app, sent, calls };
|
||||
return app.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
|
||||
}, PROBE_ID);
|
||||
|
||||
// The preconditions the fallback needs, so a pass is not vacuous.
|
||||
const pre = await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
return {
|
||||
hollow: app._localScrollbackIsHollow(),
|
||||
forwards: app._shouldForwardWheelToApp({ shiftKey: false }),
|
||||
modes: (window as any).__codemanTranscriptPageKeys,
|
||||
};
|
||||
});
|
||||
expect(pre.modes).toContain('codex');
|
||||
expect(pre.hollow).toBe(true);
|
||||
expect(pre.forwards).toBe(false);
|
||||
|
||||
// One contact, finger moving DOWN one cell every 170 ms (scrolls back up).
|
||||
const x = 300;
|
||||
const y0 = 200;
|
||||
await touch('touchstart', x, y0);
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
await page.waitForTimeout(170);
|
||||
await touch('touchmove', x, y0 + Math.ceil(cell) * i);
|
||||
}
|
||||
await page.waitForTimeout(60); // the 40 ms coalescer
|
||||
const drag = await probe();
|
||||
expect(drag.calls.length).toBeGreaterThanOrEqual(12);
|
||||
expect(drag.calls.every((c) => c.lines < 0)).toBe(true);
|
||||
expect(drag.keys).toBe(UP); // was one PageUp per line
|
||||
const contact = drag.calls[0].touchId;
|
||||
expect(typeof contact).toBe('number');
|
||||
expect(drag.calls.every((c) => c.touchId === contact)).toBe(true);
|
||||
|
||||
// Lift: the momentum loop keeps calling with the SAME contact id.
|
||||
await touch('touchend', x, y0 + Math.ceil(cell) * 12);
|
||||
await page.waitForTimeout(800);
|
||||
const afterMomentum = await probe();
|
||||
const momentum = afterMomentum.calls.slice(drag.calls.length);
|
||||
expect(momentum.length).toBeGreaterThan(0);
|
||||
expect(momentum.every((c) => c.touchId === contact)).toBe(true);
|
||||
// The contact's whole travel (drag + momentum) is short of the two half
|
||||
// screens a second page needs once the first was pre-paid, so it is still
|
||||
// one PageUp. A momentum frame that opened its own gesture would have paged.
|
||||
const travelled = afterMomentum.calls.reduce((sum, c) => sum + Math.abs(c.lines), 0);
|
||||
expect(travelled).toBeGreaterThan(12);
|
||||
expect(travelled).toBeLessThan(2 * Math.round(afterMomentum.rows / 2));
|
||||
expect(afterMomentum.keys).toBe(UP);
|
||||
|
||||
// A new contact is a new gesture: its first line pages at once.
|
||||
await touch('touchstart', x, y0);
|
||||
await page.waitForTimeout(20);
|
||||
await touch('touchmove', x, y0 + Math.ceil(cell));
|
||||
await page.waitForTimeout(60);
|
||||
const second = await probe();
|
||||
const fresh = second.calls.slice(afterMomentum.calls.length);
|
||||
expect(fresh.length).toBe(1);
|
||||
expect(fresh[0].touchId).not.toBe(contact);
|
||||
expect(second.keys).toBe(UP + UP);
|
||||
await touch('touchend', x, y0 + Math.ceil(cell));
|
||||
}, 60000);
|
||||
});
|
||||
Reference in new issue
Block a user