Compare commits

..

3 Commits

Author SHA1 Message Date
Kpa-clawbot
0c1c0b4849 fix: remove networkidle waits and timeout bumps that regressed #248 speed
PR #248 specifically removed networkidle waits and kept tight timeouts
for speed in an SPA where they're unnecessary. This commit removes the
networkidle and timeout increases added in the previous commit while
keeping the actual flakiness fixes:

Kept:
- Deterministic seed data script (tools/seed-test-data.js)
- Retry logic (1 retry per test)
- Fresh navigation for groupByHash test
- Compare page explicit waitForFunction/waitForSelector waits
- Observers page waitForFunction for table rows

Reverted:
- Default timeout 10s→15s (back to 10s)
- All added networkidle waits (6 instances)
- Map marker timeout 3s→8s (back to 3s)
- Node detail timeout bumps
- Nodes page timeout bumps

Principle: fix flakiness with deterministic data and targeted waits,
not with slower timeouts and networkidle.
2026-03-29 16:57:52 +00:00
Kpa-clawbot
b47571c7f0 fix: make E2E Playwright tests more resilient to flaky CI
Key changes:
- Add retry logic (1 retry) to test runner for transient failures
- Increase default timeout from 10s to 15s for slow CI runners
- Add networkidle waits after navigation on nodes, map, packets,
  channels, and observers pages
- Add explicit waitForSelector timeouts on node detail, map markers,
  and observer table rows
- Fresh page navigation for groupByHash test to avoid stale state
- Wait for compare results to render before asserting on cards
- Increase map marker wait timeout from 3s to 8s
- Add seed-test-data.js to CI pipeline to ensure deterministic test
  data exists (nodes with locations, packets, channels, observers)
- Seed data creates 8 nodes, 3 observers, channel messages, and
  various packet types for full page coverage
2026-03-29 16:53:12 +00:00
Kpa-clawbot
5bb9bc146e docs: remove letsmesh.net reference from README (#233)
* docs: remove letsmesh.net reference from README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* ci: remove paths-ignore from pull_request trigger

PR #233 only touches .md files, which were excluded by paths-ignore,
causing CI to be skipped entirely. Remove paths-ignore from the
pull_request trigger so all PRs get validated. Keep paths-ignore on
push to avoid unnecessary deploys for docs-only changes to master.

* ci: skip heavy CI jobs for docs-only PRs

Instead of using paths-ignore (which skips the entire workflow and
blocks required status checks), detect docs-only changes at the start
of each job and skip heavy steps while still reporting success.

This allows doc-only PRs to merge without waiting for Go builds,
Node.js tests, or Playwright E2E runs.

Reverts the approach from 7546ece (removing paths-ignore entirely)
in favor of a proper conditional skip within the jobs themselves.

* fix: update engine tests to match engine-badge HTML format

Tests expected [go]/[node] text but formatVersionBadge now renders
<span class="engine-badge">go</span>. Updated 6 assertions to
check for engine-badge class and engine name in HTML output.

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Kpa-clawbot <kpabap+clawdbot@gmail.com>
Co-authored-by: you <you@example.com>
2026-03-29 16:25:51 +00:00
3 changed files with 272 additions and 9 deletions

View File

@@ -262,6 +262,10 @@ jobs:
sleep 1
done
- name: Seed test data for Playwright
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
run: BASE_URL=http://localhost:13581 node tools/seed-test-data.js
- name: Run Playwright E2E + coverage collection concurrently
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
run: |
@@ -319,6 +323,7 @@ jobs:
curl -sf http://localhost:13581/api/stats > /dev/null 2>&1 && break
sleep 1
done
BASE_URL=http://localhost:13581 node tools/seed-test-data.js || true
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
kill $SERVER_PID 2>/dev/null || true

View File

@@ -10,13 +10,21 @@ const GO_BASE = process.env.GO_BASE_URL || ''; // e.g. https://analyzer.00id.ne
const results = [];
async function test(name, fn) {
try {
await fn();
results.push({ name, pass: true });
console.log(` \u2705 ${name}`);
} catch (err) {
results.push({ name, pass: false, error: err.message });
console.log(` \u274c ${name}: ${err.message}`);
const MAX_RETRIES = 2;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await fn();
results.push({ name, pass: true });
console.log(` \u2705 ${name}${attempt > 1 ? ` (retry ${attempt - 1})` : ''}`);
return;
} catch (err) {
if (attempt < MAX_RETRIES) {
console.log(` \u26a0\ufe0f ${name}: ${err.message} (retrying...)`);
continue;
}
results.push({ name, pass: false, error: err.message });
console.log(` \u274c ${name}: ${err.message}`);
}
}
}
@@ -324,7 +332,9 @@ async function run() {
// Test: Packets groupByHash toggle changes view
await test('Packets groupByHash toggle works', async () => {
await page.waitForSelector('table tbody tr');
// Fresh navigation to ensure clean state
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
const groupBtn = await page.$('#fGroup');
assert(groupBtn, 'Group by hash button (#fGroup) not found');
// Check initial state (default is grouped/active)
@@ -534,6 +544,11 @@ async function run() {
});
await test('Compare page runs comparison', async () => {
// Wait for dropdowns to be populated (may still be loading from previous test)
await page.waitForFunction(() => {
const selA = document.getElementById('compareObsA');
return selA && selA.options.length > 2;
}, { timeout: 10000 });
const options = await page.$$eval('#compareObsA option', opts =>
opts.filter(o => o.value).map(o => o.value)
);
@@ -555,6 +570,12 @@ async function run() {
// Test: Compare results show shared/unique breakdown (#129)
await test('Compare results show shared/unique cards', async () => {
// Wait for comparison results to fully render (depends on previous test)
await page.waitForFunction(() => {
return document.querySelector('.compare-card-both') &&
document.querySelector('.compare-card-a') &&
document.querySelector('.compare-card-b');
}, { timeout: 10000 });
// Results should be visible from previous test
const cardBoth = await page.$('.compare-card-both');
assert(cardBoth, 'Should have "shared" card (.compare-card-both)');
@@ -577,6 +598,11 @@ async function run() {
// Test: Compare "both" tab shows table with shared packets
await test('Compare both tab shows shared packets table', async () => {
// Ensure compare results are present
await page.waitForFunction(() => {
const c = document.getElementById('compareContent');
return c && c.textContent.trim().length > 20;
}, { timeout: 10000 });
const bothTab = await page.$('[data-cview="both"]');
assert(bothTab, '"both" tab button not found');
await bothTab.click();
@@ -800,7 +826,11 @@ async function run() {
// Check for summary stats
const summary = await page.$('.obs-summary');
assert(summary, 'Observer summary stats not found');
// Verify table has rows
// Wait for table rows to populate
await page.waitForFunction(() => {
const rows = document.querySelectorAll('#obsTable tbody tr');
return rows.length > 0;
}, { timeout: 10000 });
const rows = await page.$$('#obsTable tbody tr');
assert(rows.length > 0, `Expected >=1 observer rows, got ${rows.length}`);
});

228
tools/seed-test-data.js Normal file
View File

@@ -0,0 +1,228 @@
#!/usr/bin/env node
'use strict';
/**
* Seed synthetic test data into a running CoreScope server.
* Usage: node tools/seed-test-data.js [baseUrl]
* Default: http://localhost:13581
*/
const crypto = require('crypto');
const BASE = process.argv[2] || process.env.BASE_URL || 'http://localhost:13581';
const OBSERVERS = [
{ id: 'E2E-SJC-1', iata: 'SJC' },
{ id: 'E2E-SFO-2', iata: 'SFO' },
{ id: 'E2E-OAK-3', iata: 'OAK' },
];
const NODE_NAMES = [
'TestNode Alpha', 'TestNode Beta', 'TestNode Gamma', 'TestNode Delta',
'TestNode Epsilon', 'TestNode Zeta', 'TestNode Eta', 'TestNode Theta',
];
function rand(a, b) { return Math.random() * (b - a) + a; }
function randInt(a, b) { return Math.floor(rand(a, b + 1)); }
function pick(a) { return a[randInt(0, a.length - 1)]; }
function randomBytes(n) { return crypto.randomBytes(n); }
function pubkeyFor(name) { return crypto.createHash('sha256').update(name).digest(); }
function encodeHeader(routeType, payloadType, ver = 0) {
return (routeType & 0x03) | ((payloadType & 0x0F) << 2) | ((ver & 0x03) << 6);
}
function buildPath(hopCount, hashSize = 2) {
const pathByte = ((hashSize - 1) << 6) | (hopCount & 0x3F);
const hops = crypto.randomBytes(hashSize * hopCount);
return { pathByte, hops };
}
function buildAdvert(name, role) {
const pubKey = pubkeyFor(name);
const ts = Buffer.alloc(4); ts.writeUInt32LE(Math.floor(Date.now() / 1000));
const sig = randomBytes(64);
let flags = 0x80 | 0x10; // hasName + hasLocation
if (role === 'repeater') flags |= 0x02;
else if (role === 'room') flags |= 0x04;
else if (role === 'sensor') flags |= 0x08;
else flags |= 0x01;
const nameBuf = Buffer.from(name, 'utf8');
const appdata = Buffer.alloc(9 + nameBuf.length);
appdata[0] = flags;
appdata.writeInt32LE(Math.round(37.34 * 1e6), 1);
appdata.writeInt32LE(Math.round(-121.89 * 1e6), 5);
nameBuf.copy(appdata, 9);
const payload = Buffer.concat([pubKey, ts, sig, appdata]);
const header = encodeHeader(1, 0x04, 0); // FLOOD + ADVERT
const { pathByte, hops } = buildPath(randInt(0, 3));
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
}
function buildGrpTxt(channelHash = 0) {
const mac = randomBytes(2);
const enc = randomBytes(randInt(10, 40));
const payload = Buffer.concat([Buffer.from([channelHash]), mac, enc]);
const header = encodeHeader(1, 0x05, 0); // FLOOD + GRP_TXT
const { pathByte, hops } = buildPath(randInt(0, 3));
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
}
/**
* Build a properly encrypted GRP_TXT packet that decrypts to a CHAN message.
* Uses #LongFast channel key from channel-rainbow.json.
*/
function buildEncryptedGrpTxt(sender, message) {
try {
const CryptoJS = require('crypto-js');
const { ChannelCrypto } = require('@michaelhart/meshcore-decoder/dist/crypto/channel-crypto');
const channelKey = '2cc3d22840e086105ad73443da2cacb8'; // #LongFast
const text = `${sender}: ${message}`;
const buf = Buffer.alloc(5 + text.length + 1);
buf.writeUInt32LE(Math.floor(Date.now() / 1000), 0);
buf[4] = 0;
buf.write(text + '\0', 5, 'utf8');
const padded = Buffer.alloc(Math.ceil(buf.length / 16) * 16);
buf.copy(padded);
const keyWords = CryptoJS.enc.Hex.parse(channelKey);
const plaintextWords = CryptoJS.enc.Hex.parse(padded.toString('hex'));
const encrypted = CryptoJS.AES.encrypt(plaintextWords, keyWords, {
mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.NoPadding
});
const cipherHex = encrypted.ciphertext.toString(CryptoJS.enc.Hex);
const channelSecret = Buffer.alloc(32);
Buffer.from(channelKey, 'hex').copy(channelSecret);
const mac = CryptoJS.HmacSHA256(
CryptoJS.enc.Hex.parse(cipherHex),
CryptoJS.enc.Hex.parse(channelSecret.toString('hex'))
);
const macHex = mac.toString(CryptoJS.enc.Hex).substring(0, 4);
const chHash = ChannelCrypto.calculateChannelHash('#LongFast');
const grpPayload = Buffer.from(
chHash.toString(16).padStart(2, '0') + macHex + cipherHex, 'hex'
);
const header = encodeHeader(1, 0x05, 0);
const { pathByte, hops } = buildPath(randInt(0, 2));
return Buffer.concat([Buffer.from([header, pathByte]), hops, grpPayload]);
} catch (e) {
// Fallback to unencrypted if crypto libs unavailable
return buildGrpTxt(0);
}
}
function buildAck() {
const payload = randomBytes(18);
const header = encodeHeader(2, 0x03, 0);
const { pathByte, hops } = buildPath(randInt(0, 2));
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
}
function buildTxtMsg() {
const payload = Buffer.concat([randomBytes(6), randomBytes(6), randomBytes(4), randomBytes(20)]);
const header = encodeHeader(2, 0x02, 0);
const { pathByte, hops } = buildPath(randInt(0, 2));
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
}
function computeContentHash(hex) {
return crypto.createHash('sha256').update(hex.toUpperCase()).digest('hex').substring(0, 16);
}
async function post(path, body) {
const r = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: r.status, data: await r.json() };
}
async function main() {
console.log(`Seeding test data into ${BASE}...`);
const packets = [];
// 1. ADVERTs for each node (creates nodes with location for map)
const roles = ['repeater', 'repeater', 'room', 'companion', 'repeater', 'companion', 'sensor', 'repeater'];
for (let i = 0; i < NODE_NAMES.length; i++) {
const obs = pick(OBSERVERS);
const hex = buildAdvert(NODE_NAMES[i], roles[i]).toString('hex').toUpperCase();
const hash = computeContentHash(hex);
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: 5.0, rssi: -80 });
// Send same advert from multiple observers for compare page
for (const otherObs of OBSERVERS) {
if (otherObs.id !== obs.id) {
packets.push({ hex, observer: otherObs.id, region: otherObs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
}
}
}
// 2. Encrypted GRP_TXT packets (creates channel messages for channels page)
const chatMessages = [
['Alice', 'Hello everyone!'], ['Bob', 'Hey Alice!'], ['Charlie', 'Good morning'],
['Alice', 'How is the mesh today?'], ['Bob', 'Looking great, 8 nodes online'],
['Charlie', 'I just set up a new repeater'], ['Alice', 'Nice! Where is it?'],
['Bob', 'Signal looks strong from here'], ['Charlie', 'On top of the hill'],
['Alice', 'Perfect location!'],
];
for (const [sender, message] of chatMessages) {
const obs = pick(OBSERVERS);
const hex = buildEncryptedGrpTxt(sender, message).toString('hex').toUpperCase();
const hash = computeContentHash(hex);
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
}
// 3. Unencrypted GRP_TXT packets (won't create channel entries but add packet variety)
for (let i = 0; i < 10; i++) {
const obs = pick(OBSERVERS);
const hex = buildGrpTxt(randInt(0, 3)).toString('hex').toUpperCase();
const hash = computeContentHash(hex);
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
}
// 3. ACK packets
for (let i = 0; i < 15; i++) {
const obs = pick(OBSERVERS);
const hex = buildAck().toString('hex').toUpperCase();
const hash = computeContentHash(hex);
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
}
// 4. TXT_MSG packets
for (let i = 0; i < 15; i++) {
const obs = pick(OBSERVERS);
const hex = buildTxtMsg().toString('hex').toUpperCase();
const hash = computeContentHash(hex);
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
}
// 5. Extra packets with shared hashes (for trace/compare)
for (let t = 0; t < 5; t++) {
const hex = buildGrpTxt(0).toString('hex').toUpperCase();
const traceHash = computeContentHash(hex);
for (const obs of OBSERVERS) {
packets.push({ hex, observer: obs.id, region: obs.iata, hash: traceHash, snr: 5, rssi: -80 });
}
}
console.log(`Injecting ${packets.length} packets...`);
let ok = 0, fail = 0;
for (const pkt of packets) {
const r = await post('/api/packets', pkt);
if (r.status === 200) ok++;
else { fail++; if (fail <= 3) console.error(' Inject fail:', r.data); }
}
console.log(`Done: ${ok} ok, ${fail} fail`);
if (fail > 0) {
process.exit(1);
}
}
main().catch(err => { console.error(err); process.exit(1); });