Files
wadamesh/web/scripts/node-tour.mjs
T

176 lines
8.5 KiB
JavaScript

// web/scripts/node-tour.mjs: the page connected to a node, in a real browser. A mock
// node (web/mesh/test/mock_ws_node.py) stands in for a MeshCore companion's WebSocket
// server; the page connects to it through Connect > Wi-Fi, exactly as to a device.
//
// node web/scripts/node-tour.mjs [--url http://localhost:8187/] [--ws localhost:18765]
// [--shots web/docs]
// Checks: the chooser, contacts / channels / waiting messages from the node, a DM that
// gets Delivered, a live message, reload reconnects (same data dir), Disconnect returns
// to the demo with the demo's own data. Screenshots: web/docs/node-*.png
import { createRequire } from 'node:module';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(here, '..', 'package.json'));
const pw = require('playwright');
const arg = (name, dflt) => {
const i = process.argv.indexOf(name);
return i > 0 && i + 1 < process.argv.length ? process.argv[i + 1] : dflt;
};
const URL_ = arg('--url', 'http://localhost:8187/');
const WS = arg('--ws', 'localhost:18765');
const SHOTS = arg('--shots', path.join(here, '..', 'docs'));
fs.mkdirSync(SHOTS, { recursive: true });
const W = 800, H = 480;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const results = {};
let failures = 0;
const check = (name, ok, extra) => {
results[name] = extra === undefined ? !!ok : { ok: !!ok, ...extra };
if (!ok) { failures++; console.error('FAIL', name, extra || ''); } else console.log('ok ', name);
};
async function tap(page, x, y) {
const r = await page.locator('#screen').boundingBox();
await page.mouse.move(r.x + (x + 0.5) * r.width / W, r.y + (y + 0.5) * r.height / H);
await page.mouse.down(); await sleep(80); await page.mouse.up(); await sleep(150);
}
async function shot(page, name) { await page.screenshot({ path: path.join(SHOTS, `node-${name}.png`) }); }
const badge = (page) => page.locator('#mode').textContent();
async function booted(page) {
await page.waitForFunction(() => window.wadamesh && window.wadamesh.running(), null, { timeout: 60000 });
}
async function waitBadge(page, re, ms = 20000) {
const t0 = Date.now();
while (Date.now() - t0 < ms) { if (re.test(await badge(page))) return true; await sleep(250); }
return false;
}
const inStorage = (page, needle) => page.evaluate((n) => window.wadamesh.findInStorage(n), needle);
const browser = await pw.chromium.launch();
// The visitor is in Ghent and lets the page use the location (step 6: the privacy rule).
const GHENT = { latitude: 51.05, longitude: 3.72 };
const ctx = await browser.newContext({ viewport: { width: 1100, height: 760 }, geolocation: GHENT,
permissions: ['geolocation'] });
const page = await ctx.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
// (an http page that is not localhost: the browser notes it ignores COOP; that is the point)
page.on('console', (m) => { if (m.type() === 'error' && !/Cross-Origin-Opener-Policy header has been ignored/.test(m.text())) errors.push(m.text()); });
// 1. The demo, then the chooser.
await page.goto(URL_, { waitUntil: 'load' });
await booted(page);
await sleep(4000);
await tap(page, 60, 283); // the demo's welcome: Skip
await sleep(2500);
check('demo badge', /demo/i.test(await badge(page)), { badge: await badge(page) });
await shot(page, '01-demo');
await page.click('#btn-connect');
await page.waitForSelector('#connect-dlg[open]');
const kinds = await page.evaluate(() => [...document.querySelectorAll('#connect-dlg [data-kind]')]
.filter((b) => !b.hidden).map((b) => b.dataset.kind));
check('chooser offers the transports this browser has', kinds.includes('ws'), { kinds });
await shot(page, '02-chooser');
// 2. Connect over Wi-Fi (the mock's WebSocket server).
await page.fill('#ws-host', WS);
await page.click('#connect-dlg [data-kind="ws"]');
await page.waitForLoadState('load');
await booted(page);
check('connected', await waitBadge(page, /^Connected: Mock Node over Wi-Fi$/), { badge: await badge(page) });
check('node data dir', (await page.evaluate(() => window.wadamesh.dataDir)).startsWith('/data/node-'));
await sleep(2500);
await shot(page, '03-connected-home');
await tap(page, 80, 456); // Chats
await sleep(1500);
await shot(page, '04-chats');
await tap(page, 240, 456); // Contacts
await sleep(1500);
await shot(page, '05-contacts');
// 3. A DM to Alice: Delivered, and her reply.
await tap(page, 80, 456);
await sleep(1200);
await tap(page, 400, 152); // Alice (second row: #wadatest has the newest message)
await sleep(1500);
await tap(page, 380, 447);
await sleep(600);
await page.keyboard.type('hello from the browser', { delay: 20 });
await page.keyboard.press('Enter');
await sleep(5000);
await shot(page, '06-dm-delivered');
await page.evaluate(() => window.wadamesh.flush());
check('DM reply from the node stored', (await inStorage(page, 'mock reply: got it')).length > 0);
// 4. The live channel post the mock sends 8 s after connecting.
await page.keyboard.press('Escape');
await sleep(1500);
await shot(page, '07-live');
await page.evaluate(() => window.wadamesh.flush());
check('live message received', (await inStorage(page, 'live hello from the mock')).length > 0);
check('waiting message synced', (await inStorage(page, 'hello from the mock (waiting)')).length > 0);
const nodeDir = await page.evaluate(() => window.wadamesh.dataDir);
// 5. Reload: reconnects by itself, into the node's own data dir (now keyed by the node).
await page.reload({ waitUntil: 'load' });
await booted(page);
check('reconnects after reload', await waitBadge(page, /^Connected: Mock Node/), { badge: await badge(page) });
const dir2 = await page.evaluate(() => window.wadamesh.dataDir);
check('data dir keyed by the node', /^\/data\/node-[0-9a-f]{8}$/.test(dir2), { before: nodeDir, after: dir2 });
await sleep(2000);
await tap(page, 80, 456);
await sleep(1500);
await shot(page, '08-after-reload');
const hits = await inStorage(page, 'hello from the browser');
check('history kept across the reload', hits.some((h) => h.startsWith(dir2)), { hits });
// 6. Location privacy: with the GPS on, this computer's position reaches the node ONLY
// while the node's own "share position in adverts" is on (WEB_BUILD.md "Location").
const mockState = () => fetch(`http://${WS}/latlon`).then((r) => r.json());
if (!(await page.evaluate(() => window.isSecureContext))) {
results['location privacy'] = 'skipped (an insecure http page has no location)';
} else {
for (const t of ['tap 720 456', 'wait 1500']) if (t.startsWith('tap')) await tap(page, ...t.split(' ').slice(1).map(Number)); else await sleep(1500);
{
const r = await page.locator('#screen').boundingBox();
const at = (x, y) => [r.x + (x + 0.5) * r.width / W, r.y + (y + 0.5) * r.height / H];
await page.mouse.move(...at(200, 420)); await page.mouse.down();
for (let i = 1; i <= 24; i++) { await page.mouse.move(...at(200, 420 - 380 * i / 24)); await sleep(16); }
await sleep(400); await page.mouse.up(); await sleep(1500); // Settings, scrolled to the end
}
await tap(page, 100, 302); // GPS
await sleep(1500);
await tap(page, 745, 95); // GPS on
await sleep(9000);
await shot(page, '10-gps-on');
check('share off: the position stays in the browser', (await mockState()).latlon.length === 0, await mockState());
await fetch(`http://${WS}/share/1`); // the node now shares its position (as if set in the phone app)
await page.reload({ waitUntil: 'load' });
await booted(page);
await waitBadge(page, /^Connected: Mock Node/);
await sleep(9000);
const shared = (await mockState()).latlon;
check('share on: the node gets this computer\'s position',
shared.length >= 1 && Math.abs(shared[0][0] - GHENT.latitude) < 0.001 && Math.abs(shared[0][1] - GHENT.longitude) < 0.001,
{ latlon: shared });
await fetch(`http://${WS}/share/0`);
}
// 7. Disconnect: back to the demo, whose data never saw the node's.
await page.click('#btn-connect');
await page.waitForLoadState('load');
await booted(page);
await sleep(2500);
check('demo again', /demo/i.test(await badge(page)), { badge: await badge(page) });
const demoHits = (await inStorage(page, 'mock reply: got it')).filter((h) => h.startsWith('/data/demo'));
check('demo data has nothing from the node', demoHits.length === 0, { demoHits });
await shot(page, '09-demo-again');
check('no page errors', errors.length === 0, { errors: errors.slice(0, 5) });
console.log(JSON.stringify(results, null, 2));
await browser.close();
process.exit(failures ? 1 : 0);