mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 22:54:44 +00:00
cbfd159f8e
## 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>
226 lines
8.9 KiB
JavaScript
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);
|