Files
meshcore-analyzer/test-pull-to-reconnect.js
T
Kpa-clawbot cbfd159f8e feat(ws): pull-to-reconnect on touch devices (Fixes #1063) (#1068)
## Summary

Reframes the browser's native pull-to-refresh on touch devices as a
**WebSocket reconnect** instead of a full page reload. On data pages
(Packets, Nodes, Channels — and globally, since the WS is shared) a
downward pull at `scrollTop=0` cycles the WS, which is what users
actually want when they reach for that gesture.

Fixes #1063.

## Behavior

- **Touch-only**: gated by `('ontouchstart' in window) ||
navigator.maxTouchPoints > 0`. Desktop is untouched.
- **Scroll-safe**: every handler re-checks `scrollTop > 0` and bails out
— never hijacks normal scroll.
- **Visual affordance**: a fixed chip slides down from the top with a
rotating ⟳ icon; opacity and rotation scale with pull progress (0 →
`PULL_THRESHOLD_PX = 80px`).
- **`preventDefault` is conservative**: only after `dy > 16px` and only
on `touchmove`, so taps and short swipes are not affected.
- **Result feedback**: a brief toast — green `Connected ✓` if WS was
already OPEN, `Reconnecting…` otherwise. Both auto-dismiss after ~1.8s.
- **Reconnect path**: closes the existing WS so the existing `onclose`
auto-reconnect fires immediately; an explicit `connectWS()` is also
called as a safety net when `ws` is null.
- **No regression** to existing WS auto-reconnect — same `connectWS` /
`setTimeout(connectWS, 3000)` chain, just kicked manually.

## TDD

- **Red commit** `f90f5e9` — adds `test-pull-to-reconnect.js` with 6
assertions; stub functions added to `app.js` so tests reach assertion
failures (not ReferenceError). 3/6 fail on behavior.
- **Green commit** `53adbd9` — full implementation; 6/6 pass.

## Files

- `public/app.js` — `pullReconnect()`, `setupPullToReconnect()`,
`_ensurePullIndicator()`, `_showPullToast()`, `_isTouchDevice()`. Wired
into `DOMContentLoaded` next to `connectWS()`. Touched the WS section
only.
- `test-pull-to-reconnect.js` — vm sandbox suite covering exposure,
WS-close, listener wiring, threshold trigger, scroll-position gate.

## Acceptance criteria check

-  Pull-down at scroll-top triggers WS reconnect + data refetch
(debounced cache invalidate fires on next WS message)
-  Visible affordance during pull (rotating chip)
-  Resolves on success (toast), shows status toast on disconnect path
-  Disabled when not at `scrollTop=0`
-  No regression to existing WS auto-reconnect

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
2026-05-05 01:11:59 -07:00

226 lines
8.9 KiB
JavaScript

/* test-pull-to-reconnect.js — behavioral tests for pull-to-reconnect (#1063)
* Loads app.js in a vm sandbox, stubs WebSocket + DOM, asserts that:
* - pullReconnect() exists as a global helper
* - calling it closes the existing WS (which triggers the existing
* auto-reconnect path)
* - setupPullToReconnect() exists and wires touchstart/touchmove/touchend
* listeners on the document
* - a pull-down gesture at scrollTop=0 over the threshold triggers
* pullReconnect
* - a touch when scrollTop > 0 does NOT trigger pullReconnect (don't
* hijack normal scrolling)
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
console.log('--- test-pull-to-reconnect.js ---');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}\n ${e.stack.split('\n').slice(1, 3).join('\n ')}`); }
}
function makeSandbox(opts) {
opts = opts || {};
const listeners = {}; // event name -> [fn]
const elements = {};
function makeEl(id) {
const el = {
id, textContent: '', innerHTML: '', value: '',
style: {}, dataset: {},
_classes: new Set(),
classList: {
add: function() { for (const a of arguments) el._classes.add(a); },
remove: function() { for (const a of arguments) el._classes.delete(a); },
toggle: function(c) { if (el._classes.has(c)) el._classes.delete(c); else el._classes.add(c); },
contains: function(c) { return el._classes.has(c); },
},
addEventListener: function(ev, fn) { (el['_on_' + ev] = el['_on_' + ev] || []).push(fn); },
removeEventListener: function() {},
setAttribute: function() {}, getAttribute: function() { return null; },
appendChild: function(child) { (el._children = el._children || []).push(child); return child; },
remove: function() {},
querySelector: function() { return null; },
querySelectorAll: function() { return []; },
};
elements[id] = el;
return el;
}
// Pre-create elements app.js touches at WS time
makeEl('liveDot');
// Stub WebSocket — track instances + close calls
const wsInstances = [];
function FakeWS(url) {
this.url = url;
this.readyState = 1; // OPEN
this.closed = false;
this.onopen = null; this.onclose = null; this.onerror = null; this.onmessage = null;
wsInstances.push(this);
// simulate immediate open so onopen fires synchronously isn't required;
// tests will invoke handlers directly when needed.
}
FakeWS.prototype.close = function() {
this.closed = true;
if (typeof this.onclose === 'function') this.onclose({});
};
FakeWS.prototype.send = function() {};
const body = makeEl('body');
const ctx = {
console,
setTimeout: function(fn, ms) { return 0; }, // suppress reconnect loop
clearTimeout: function() {},
setInterval: function() { return 0; },
clearInterval: function() {},
Date, Math, JSON, Object, Array, String, Number, Boolean,
Error, RegExp, Map, Set, Symbol, Promise,
requestAnimationFrame: function(fn) { return 0; },
performance: { now: function() { return 0; } },
location: { protocol: 'http:', host: 'localhost', hash: '' },
navigator: { userAgent: 'test' },
WebSocket: FakeWS,
fetch: function() { return Promise.resolve({ ok: true, json: function() { return Promise.resolve({}); } }); },
localStorage: {
_data: {},
getItem: function(k) { return this._data[k] || null; },
setItem: function(k, v) { this._data[k] = String(v); },
removeItem: function(k) { delete this._data[k]; },
},
document: {
readyState: 'complete',
documentElement: { scrollTop: opts.scrollTop || 0, style: { setProperty: function() {} }, setAttribute: function() {}, getAttribute: function() { return null; } },
body: body,
head: { appendChild: function() {} },
createElement: function(tag) { return makeEl(tag); },
getElementById: function(id) { return elements[id] || null; },
querySelector: function() { return null; },
querySelectorAll: function() { return []; },
addEventListener: function(ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); },
removeEventListener: function() {},
dispatchEvent: function(e) { (listeners[e.type] || []).forEach(function(fn) { fn(e); }); return true; },
},
window: {
addEventListener: function() {}, removeEventListener: function() {}, dispatchEvent: function() {},
matchMedia: function() { return { matches: false, addEventListener: function() {} }; },
ontouchstart: opts.touch === false ? undefined : null,
},
CustomEvent: function(type, init) { this.type = type; this.detail = (init || {}).detail; },
};
ctx.window.location = ctx.location;
ctx.window.localStorage = ctx.localStorage;
ctx.window.document = ctx.document;
ctx.self = ctx.window;
ctx.globalThis = ctx;
vm.createContext(ctx);
return { ctx, elements, wsInstances, listeners };
}
function loadApp(box) {
const src = fs.readFileSync('public/app.js', 'utf8');
vm.runInContext(src, box.ctx);
}
console.log('\n=== pullReconnect helper exists ===');
test('pullReconnect is exposed on window', () => {
const box = makeSandbox();
loadApp(box);
assert.strictEqual(typeof box.ctx.window.pullReconnect, 'function',
'window.pullReconnect must be a function');
});
console.log('\n=== setupPullToReconnect exists ===');
test('setupPullToReconnect is exposed on window', () => {
const box = makeSandbox();
loadApp(box);
assert.strictEqual(typeof box.ctx.window.setupPullToReconnect, 'function',
'window.setupPullToReconnect must be a function');
});
console.log('\n=== pullReconnect closes existing WS ===');
test('calling pullReconnect() closes the current WebSocket', () => {
const box = makeSandbox();
loadApp(box);
// app.js does NOT call connectWS until DOMContentLoaded. Force one:
box.ctx.window.connectWS && box.ctx.window.connectWS();
// If app.js doesn't expose connectWS, fall back to invoking pullReconnect
// and checking that something tries to open a new socket.
const beforeCount = box.wsInstances.length;
box.ctx.window.pullReconnect();
// Either: existing WS got closed, OR a new WS was opened (reconnect)
const closed = box.wsInstances.some(function(w) { return w.closed; });
const opened = box.wsInstances.length > beforeCount;
assert.ok(closed || opened,
'pullReconnect must close the WS or open a new one (got closed=' + closed + ', opened=' + opened + ')');
});
console.log('\n=== setupPullToReconnect wires document touch listeners ===');
test('setupPullToReconnect attaches touchstart listener', () => {
const box = makeSandbox();
loadApp(box);
box.ctx.window.setupPullToReconnect();
assert.ok((box.listeners['touchstart'] || []).length > 0,
'touchstart listener must be attached to document');
assert.ok((box.listeners['touchmove'] || []).length > 0,
'touchmove listener must be attached to document');
assert.ok((box.listeners['touchend'] || []).length > 0,
'touchend listener must be attached to document');
});
console.log('\n=== Pull gesture at scrollTop=0 triggers reconnect ===');
test('pull-down past threshold at scrollTop=0 triggers pullReconnect', () => {
const box = makeSandbox({ scrollTop: 0 });
loadApp(box);
box.ctx.window.connectWS && box.ctx.window.connectWS();
box.ctx.window.setupPullToReconnect();
let triggered = false;
const orig = box.ctx.window.pullReconnect;
box.ctx.window.pullReconnect = function() { triggered = true; return orig.apply(this, arguments); };
function fire(name, y) {
(box.listeners[name] || []).forEach(function(fn) {
fn({ touches: [{ clientY: y }], changedTouches: [{ clientY: y }], preventDefault: function() {}, type: name });
});
}
fire('touchstart', 10);
fire('touchmove', 100);
fire('touchmove', 200);
fire('touchend', 200);
assert.ok(triggered, 'pullReconnect must be called after pull > threshold at scrollTop=0');
});
console.log('\n=== Pull gesture when scrolled DOWN does NOT trigger ===');
test('pull when scrollTop > 0 does NOT trigger pullReconnect', () => {
const box = makeSandbox({ scrollTop: 500 });
loadApp(box);
box.ctx.window.connectWS && box.ctx.window.connectWS();
box.ctx.window.setupPullToReconnect();
let triggered = false;
box.ctx.window.pullReconnect = function() { triggered = true; };
function fire(name, y) {
(box.listeners[name] || []).forEach(function(fn) {
fn({ touches: [{ clientY: y }], changedTouches: [{ clientY: y }], preventDefault: function() {}, type: name });
});
}
fire('touchstart', 10);
fire('touchmove', 200);
fire('touchend', 200);
assert.strictEqual(triggered, false,
'pullReconnect must NOT fire when page is scrolled (scrollTop > 0)');
});
console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===\n');
process.exit(failed > 0 ? 1 : 0);