mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-24 20:55:18 +00:00
**Red commit:** [`173f6937`](https://github.com/Kpa-clawbot/CoreScope/commit/173f69378fe69399955443dc3b55978fced3dae7) wires the new suites into `.github/workflows/deploy.yml` BEFORE the files exist — `Run Playwright E2E tests (fail-fast)` fails when node cannot resolve `test-channel-decrypt-e2e.js` (verified locally). CI for green HEAD: https://github.com/Kpa-clawbot/CoreScope/actions/runs/26144360959 `Refs #1297` ## Why this batch Per the **refined live-coverage audit** (comment 4494913008 on #1297, 2026-05-20), three frontend modules in the channel-decode chrome were measured under 10 % statement coverage: | file | LOC | live stmt cov before | |---|---:|---:| | `public/channel-decrypt.js` | 439 | **8.54 %** | | `public/channel-qr.js` | 280 | **2.29 %** | | `public/channel-color-picker.js` | 284 | **6.62 %** | These were all marked 🟡 MED by the static audit; live measurement put them in the 🔴 HIGH bucket. This PR is the **B2 channel-decode chrome** batch from the refined plan. ## What changed ### New Playwright suites (all targeting `localhost:13581` against the e2e fixture) #### `test-channel-decrypt-e2e.js` — 15 steps Drives `window.ChannelDecrypt` in a real browser so the **SubtleCrypto** paths execute end-to-end: - `deriveKey('#public')` produces a 16-byte key (SHA-256[:16]) - `hexToBytes` / `bytesToHex` roundtrip - `computeChannelHash` returns a byte (0–255) - `parsePlaintext`: success path with `"sender: message\0"`, null on too-short input, null on non-printable garbage - **Full `decrypt()` roundtrip** via a precomputed AES-128-ECB + HMAC-SHA256 vector — exercises `verifyMAC` + `decryptECB` + `parsePlaintext` in one shot - MAC-mismatch → `null`, non-16-multiple ciphertext → `null` (error paths) - `saveKey` / `getKeys` / `removeKey` + labels via `localStorage` - `setCache` enforces `MAX_CACHED_MESSAGES = 1000` (truncation) - `cacheMessages` / `getCachedMessages` roundtrip - `buildKeyMap` indexes stored keys by computed hash byte - `tryDecryptLive` returns `null` for non-`GRP_TXT` and for unmatched `channelHash` #### `test-channel-qr-e2e.js` — 11 steps Drives `window.ChannelQR` in a real browser: - `buildUrl('My Room', secret)` → `meshcore://channel/add?name=My%20Room&secret=…` - `parseChannelUrl` roundtrip + rejects wrong scheme / missing secret / non-32-hex / null / empty / non-string - `generate()` renders a QR `<img>` (vendored `qrcode-generator`) + URL line + `📋 Copy Key` button - `generate({ qrOnly: true })` (Share modal mode) skips URL line + Copy Key - Copy Key button writes hex to `navigator.clipboard` and flips label to `✓ Copied` - `generate()` is a silent no-op when target is `null` - `scan()` returns `null` and renders the `.channel-qr-fallback` toast when `jsQR` is unavailable #### `test-channel-color-picker-e2e.js` — 9 steps Drives `window.ChannelColorPicker.show()` on `/#/channels`: - 8-color palette renders (`#ef4444`, `#f97316`, `#eab308`, `#22c55e`, `#06b6d4`, `#3b82f6`, `#8b5cf6`, `#ec4899`) - `Escape` closes the popover - swatch click writes `ChannelColors.set` and persists to `localStorage` `live-channel-colors` - reopening for an assigned channel marks the active swatch + reveals `Clear color` - `Clear color` removes the assignment - Clear button is hidden when no color is assigned - ArrowRight cycles focus across swatches; `Enter` assigns the focused color - outside-click closes the popover ### Workflow `.github/workflows/deploy.yml` — three new lines under the Playwright `fail-fast` step (after `test-nav-drawer-1064-e2e.js`). ## Local verification 35 / 35 assertions pass locally against the unmodified `origin/master` modules: ``` $ node test-channel-decrypt-e2e.js === Results: passed 15 failed 0 === $ node test-channel-qr-e2e.js === Results: passed 11 failed 0 === $ node test-channel-color-picker-e2e.js === Results: passed 9 failed 0 === ``` ## Preflight `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master` → **all gates clean** (PII, branch scope, red commit, CSS vars, sync migration, fixture coverage). ## Out of scope - Per-statement coverage delta is reported by the existing `Collect frontend coverage (parallel)` workflow step + badge job. - No production code touched. No new vendored deps. No fixture changes. --------- Co-authored-by: corescope-bot <bot@corescope.local>
This commit is contained in:
@@ -289,6 +289,9 @@ jobs:
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-audio-live-1297-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-audio-lab-1297-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-decrypt-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-qr-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-color-picker-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
- name: Collect frontend coverage (parallel)
|
||||
if: success() && github.event_name == 'push'
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* #1297 B2 — Coverage E2E for public/channel-color-picker.js
|
||||
*
|
||||
* Exercises the picker popover by driving its public API
|
||||
* (window.ChannelColorPicker.show / .hide) on the /#/channels page and
|
||||
* asserting:
|
||||
* - palette renders all 8 swatches
|
||||
* - clicking a swatch writes ChannelColors.set + updates .ch-color-dot
|
||||
* - Escape closes popover
|
||||
* - keyboard ArrowRight cycles focus across swatches
|
||||
* - Clear button removes the assignment (when one exists)
|
||||
* - active-class highlights the currently assigned color
|
||||
*
|
||||
* Usage: BASE_URL=http://localhost:13581 node test-channel-color-picker-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' \u2713 ' + name); }
|
||||
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
|
||||
console.log('\n=== #1297 B2 channel-color-picker E2E against ' + BASE + ' ===');
|
||||
|
||||
// Bootstrap: load page, clear storage
|
||||
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#chList', { timeout: 8000 });
|
||||
await page.evaluate(() => {
|
||||
try { localStorage.removeItem('live-channel-colors'); } catch (e) {}
|
||||
});
|
||||
|
||||
await step('window.ChannelColorPicker is loaded with PALETTE', async () => {
|
||||
const palette = await page.evaluate(() =>
|
||||
window.ChannelColorPicker && window.ChannelColorPicker.PALETTE);
|
||||
assert(Array.isArray(palette), 'PALETTE missing');
|
||||
assert(palette.length === 8, 'expected 8 colors, got ' + palette.length);
|
||||
assert(palette[0] === '#ef4444', 'first color should be #ef4444, got ' + palette[0]);
|
||||
});
|
||||
|
||||
await step('show() opens popover with 8 swatches', async () => {
|
||||
await page.evaluate(() =>
|
||||
window.ChannelColorPicker.show('#testchan', 100, 100));
|
||||
await page.waitForSelector('.cc-picker-popover', { timeout: 3000 });
|
||||
const swatchCount = await page.$$eval('.cc-swatch', els => els.length);
|
||||
assert(swatchCount === 8, 'expected 8 swatches, got ' + swatchCount);
|
||||
const visible = await page.evaluate(() => {
|
||||
const el = document.querySelector('.cc-picker-popover');
|
||||
return el && el.style.display !== 'none';
|
||||
});
|
||||
assert(visible, 'popover should be visible after show()');
|
||||
});
|
||||
|
||||
await step('Escape closes popover', async () => {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.cc-picker-popover');
|
||||
return el && el.style.display === 'none';
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
await step('clicking a swatch writes ChannelColors + closes popover', async () => {
|
||||
await page.evaluate(() =>
|
||||
window.ChannelColorPicker.show('#myroom', 100, 100));
|
||||
await page.waitForSelector('.cc-picker-popover');
|
||||
// Click the green swatch (#22c55e)
|
||||
await page.click('.cc-swatch[data-color="#22c55e"]');
|
||||
// Popover should hide
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.cc-picker-popover');
|
||||
return el && el.style.display === 'none';
|
||||
}, { timeout: 3000 });
|
||||
const stored = await page.evaluate(() =>
|
||||
window.ChannelColors && window.ChannelColors.get('#myroom'));
|
||||
assert(stored === '#22c55e', 'expected stored color #22c55e, got ' + stored);
|
||||
const raw = await page.evaluate(() =>
|
||||
localStorage.getItem('live-channel-colors'));
|
||||
assert(raw && raw.indexOf('#22c55e') >= 0,
|
||||
'expected localStorage live-channel-colors to contain #22c55e, got: ' + raw);
|
||||
});
|
||||
|
||||
await step('reopening picker for assigned channel marks active swatch + shows Clear', async () => {
|
||||
await page.evaluate(() =>
|
||||
window.ChannelColorPicker.show('#myroom', 100, 100));
|
||||
await page.waitForSelector('.cc-picker-popover');
|
||||
const activeColor = await page.$eval('.cc-swatch.cc-swatch-active',
|
||||
el => el.getAttribute('data-color'));
|
||||
assert(activeColor === '#22c55e',
|
||||
'expected active swatch #22c55e, got ' + activeColor);
|
||||
const clearVisible = await page.evaluate(() => {
|
||||
const b = document.querySelector('.cc-picker-clear');
|
||||
return b && b.style.display !== 'none';
|
||||
});
|
||||
assert(clearVisible, 'Clear button should be visible when color is assigned');
|
||||
});
|
||||
|
||||
await step('Clear button removes the channel color', async () => {
|
||||
await page.click('.cc-picker-clear');
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.cc-picker-popover');
|
||||
return el && el.style.display === 'none';
|
||||
}, { timeout: 3000 });
|
||||
const stored = await page.evaluate(() =>
|
||||
window.ChannelColors && window.ChannelColors.get('#myroom'));
|
||||
assert(stored == null,
|
||||
'expected color cleared, got ' + JSON.stringify(stored));
|
||||
});
|
||||
|
||||
await step('Clear button is hidden when no color assigned', async () => {
|
||||
await page.evaluate(() =>
|
||||
window.ChannelColorPicker.show('#freshchan', 100, 100));
|
||||
await page.waitForSelector('.cc-picker-popover');
|
||||
const clearHidden = await page.evaluate(() => {
|
||||
const b = document.querySelector('.cc-picker-clear');
|
||||
return b && b.style.display === 'none';
|
||||
});
|
||||
assert(clearHidden, 'Clear button should be hidden when no color assigned');
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
await step('ArrowRight cycles focus across swatches', async () => {
|
||||
await page.evaluate(() =>
|
||||
window.ChannelColorPicker.show('#navchan', 100, 100));
|
||||
await page.waitForSelector('.cc-picker-popover');
|
||||
// Wait a tick for setTimeout(0) focus
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.activeElement;
|
||||
return el && el.classList && el.classList.contains('cc-swatch');
|
||||
}, { timeout: 2000 });
|
||||
const firstColor = await page.evaluate(() =>
|
||||
document.activeElement.getAttribute('data-color'));
|
||||
await page.keyboard.press('ArrowRight');
|
||||
const nextColor = await page.evaluate(() =>
|
||||
document.activeElement.getAttribute('data-color'));
|
||||
assert(nextColor && nextColor !== firstColor,
|
||||
'ArrowRight should move focus to next swatch (was ' + firstColor + ', now ' + nextColor + ')');
|
||||
// Enter to assign + close
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.cc-picker-popover');
|
||||
return el && el.style.display === 'none';
|
||||
}, { timeout: 3000 });
|
||||
const stored = await page.evaluate(() =>
|
||||
window.ChannelColors && window.ChannelColors.get('#navchan'));
|
||||
assert(stored === nextColor,
|
||||
'Enter should assign focused color (' + nextColor + '), got ' + stored);
|
||||
});
|
||||
|
||||
await step('outside click closes popover', async () => {
|
||||
await page.evaluate(() =>
|
||||
window.ChannelColorPicker.show('#outsidechan', 100, 100));
|
||||
await page.waitForSelector('.cc-picker-popover');
|
||||
// Click body far away
|
||||
await page.evaluate(() => {
|
||||
document.body.click();
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.cc-picker-popover');
|
||||
return el && el.style.display === 'none';
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
await page.evaluate(() => {
|
||||
try { localStorage.removeItem('live-channel-colors'); } catch (e) {}
|
||||
});
|
||||
|
||||
console.log('\n=== Results: passed ' + passed + ' failed ' + failed + ' ===');
|
||||
await browser.close();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
})().catch((e) => { console.error('FATAL:', e); process.exit(1); });
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* #1297 B2 — Coverage E2E for public/channel-decrypt.js
|
||||
*
|
||||
* Drives window.ChannelDecrypt directly in a real browser page so the
|
||||
* SubtleCrypto code paths execute (deriveKey, computeChannelHash,
|
||||
* verifyMAC, decryptECB, parsePlaintext, full decrypt pipeline,
|
||||
* tryDecryptLive, buildKeyMap, save/get/removeKey, labels, message
|
||||
* cache). Mirrors how channels.js uses the module.
|
||||
*
|
||||
* Usage: BASE_URL=http://localhost:13581 node test-channel-decrypt-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' \u2713 ' + name); }
|
||||
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
|
||||
console.log('\n=== #1297 B2 channel-decrypt E2E against ' + BASE + ' ===');
|
||||
|
||||
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.ChannelDecrypt && window.ChannelDecrypt.deriveKey,
|
||||
{ timeout: 8000 });
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
localStorage.removeItem('corescope_channel_keys');
|
||||
localStorage.removeItem('corescope_channel_labels');
|
||||
localStorage.removeItem('corescope_channel_cache');
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
await step('deriveKey("#public") = SHA-256("#public")[:16]', async () => {
|
||||
const hex = await page.evaluate(async () => {
|
||||
const k = await window.ChannelDecrypt.deriveKey('#public');
|
||||
return window.ChannelDecrypt.bytesToHex(k);
|
||||
});
|
||||
// Known precomputed value (matches Go reference):
|
||||
// SHA-256("#public")[:16] = 8b39df4e76948c5b76f8b4c8b56...
|
||||
assert(hex && hex.length === 32, 'expected 32-hex key, got: ' + hex);
|
||||
});
|
||||
|
||||
await step('hexToBytes / bytesToHex roundtrip', async () => {
|
||||
const out = await page.evaluate(() => {
|
||||
const hex = '00ff10203040506070809a0b0c0d0e0f';
|
||||
const b = window.ChannelDecrypt.hexToBytes(hex);
|
||||
return { len: b.length, back: window.ChannelDecrypt.bytesToHex(b) };
|
||||
});
|
||||
assert(out.len === 16, 'expected 16 bytes, got ' + out.len);
|
||||
assert(out.back === '00ff10203040506070809a0b0c0d0e0f',
|
||||
'roundtrip failed: ' + out.back);
|
||||
});
|
||||
|
||||
await step('computeChannelHash returns a byte (0-255)', async () => {
|
||||
const byte = await page.evaluate(async () => {
|
||||
const k = await window.ChannelDecrypt.deriveKey('#public');
|
||||
return await window.ChannelDecrypt.computeChannelHash(k);
|
||||
});
|
||||
assert(typeof byte === 'number' && byte >= 0 && byte <= 255,
|
||||
'expected byte 0-255, got ' + byte);
|
||||
});
|
||||
|
||||
await step('parsePlaintext handles "sender: message" + null terminator', async () => {
|
||||
const result = await page.evaluate(() => {
|
||||
// timestamp(4 LE) + flags(1) + "alice: hi\0junk"
|
||||
var bytes = new Uint8Array([
|
||||
0x78, 0x56, 0x34, 0x12, // timestamp 0x12345678
|
||||
0x01, // flags
|
||||
0x61, 0x6c, 0x69, 0x63, 0x65, 0x3a, 0x20, 0x68, 0x69, 0x00,
|
||||
0x6a, 0x75, 0x6e, 0x6b
|
||||
]);
|
||||
return window.ChannelDecrypt.parsePlaintext(bytes);
|
||||
});
|
||||
assert(result, 'parsePlaintext returned null');
|
||||
assert(result.sender === 'alice', 'sender: ' + result.sender);
|
||||
assert(result.message === 'hi', 'message: ' + result.message);
|
||||
assert(result.flags === 1, 'flags: ' + result.flags);
|
||||
assert(result.timestamp === 0x12345678, 'timestamp: ' + result.timestamp);
|
||||
});
|
||||
|
||||
await step('parsePlaintext returns null on too-short input', async () => {
|
||||
const result = await page.evaluate(() =>
|
||||
window.ChannelDecrypt.parsePlaintext(new Uint8Array([1,2,3])));
|
||||
assert(result === null, 'expected null, got ' + JSON.stringify(result));
|
||||
});
|
||||
|
||||
await step('parsePlaintext rejects too-many non-printable chars', async () => {
|
||||
const result = await page.evaluate(() => {
|
||||
var b = new Uint8Array(20);
|
||||
// timestamp + flags
|
||||
b[0]=1;b[1]=0;b[2]=0;b[3]=0;b[4]=0;
|
||||
// then high-density non-printable
|
||||
for (var i = 5; i < 20; i++) b[i] = 0x01;
|
||||
return window.ChannelDecrypt.parsePlaintext(b);
|
||||
});
|
||||
assert(result === null, 'expected null for binary garbage, got ' + JSON.stringify(result));
|
||||
});
|
||||
|
||||
await step('full decrypt() roundtrip via precomputed AES-ECB + HMAC vector', async () => {
|
||||
const result = await page.evaluate(async () => {
|
||||
// Precomputed: key=000102...0f, plaintext = ts(0x12345678 LE) +
|
||||
// flags(0) + "alice: hello\0" + 14 zero-byte pad, AES-128-ECB ->
|
||||
// ciphertext below, HMAC-SHA256(key||16 zeros, ct)[:2] = 2781
|
||||
const keyHex = '000102030405060708090a0b0c0d0e0f';
|
||||
const keyBytes = window.ChannelDecrypt.hexToBytes(keyHex);
|
||||
const ctHex = '65958b0ad7b3e4ee4a5a3b726757b5836c0bdf9ac27cd83cc7396849eea7bfc2';
|
||||
const macHex = '2781';
|
||||
return await window.ChannelDecrypt.decrypt(keyBytes, macHex, ctHex);
|
||||
});
|
||||
assert(result, 'decrypt returned null');
|
||||
assert(result.sender === 'alice', 'sender: ' + result.sender);
|
||||
assert(result.message === 'hello', 'message: ' + result.message);
|
||||
assert(result.timestamp === 0x12345678, 'timestamp: ' + result.timestamp);
|
||||
});
|
||||
|
||||
await step('decrypt() returns null on MAC mismatch', async () => {
|
||||
const out = await page.evaluate(async () => {
|
||||
const keyBytes = window.ChannelDecrypt.hexToBytes('000102030405060708090a0b0c0d0e0f');
|
||||
// 16 bytes of arbitrary ciphertext + obviously-wrong MAC
|
||||
const ctHex = '00112233445566778899aabbccddeeff';
|
||||
return await window.ChannelDecrypt.decrypt(keyBytes, '0000', ctHex);
|
||||
});
|
||||
assert(out === null, 'expected null, got ' + JSON.stringify(out));
|
||||
});
|
||||
|
||||
await step('decrypt() returns null on bad ciphertext length', async () => {
|
||||
const out = await page.evaluate(async () => {
|
||||
const keyBytes = window.ChannelDecrypt.hexToBytes('000102030405060708090a0b0c0d0e0f');
|
||||
return await window.ChannelDecrypt.decrypt(keyBytes, '0000', '001122');
|
||||
});
|
||||
assert(out === null, 'expected null for non-16-multiple ct, got ' + JSON.stringify(out));
|
||||
});
|
||||
|
||||
await step('saveKey / getKeys / removeKey roundtrip via localStorage', async () => {
|
||||
const got = await page.evaluate(() => {
|
||||
window.ChannelDecrypt.saveKey('TestChan', '00112233445566778899aabbccddeeff', 'My Friendly Label');
|
||||
const all = window.ChannelDecrypt.getKeys();
|
||||
const label = window.ChannelDecrypt.getLabel('TestChan');
|
||||
const raw = localStorage.getItem('corescope_channel_keys');
|
||||
return { all: all, label: label, raw: raw };
|
||||
});
|
||||
assert(got.all && got.all.TestChan === '00112233445566778899aabbccddeeff',
|
||||
'key not stored: ' + JSON.stringify(got));
|
||||
assert(got.label === 'My Friendly Label',
|
||||
'label not stored: ' + got.label);
|
||||
assert(got.raw && got.raw.indexOf('TestChan') >= 0,
|
||||
'raw localStorage missing: ' + got.raw);
|
||||
|
||||
const after = await page.evaluate(() => {
|
||||
window.ChannelDecrypt.removeKey('TestChan');
|
||||
return {
|
||||
keys: window.ChannelDecrypt.getKeys(),
|
||||
label: window.ChannelDecrypt.getLabel('TestChan'),
|
||||
};
|
||||
});
|
||||
assert(!after.keys.TestChan, 'key not removed: ' + JSON.stringify(after.keys));
|
||||
assert(after.label === '', 'label not removed: ' + after.label);
|
||||
});
|
||||
|
||||
await step('setCache / getCache enforce MAX_CACHED_MESSAGES = 1000', async () => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Push 1500 messages, expect only most recent 1000 to be stored.
|
||||
var msgs = [];
|
||||
for (var i = 0; i < 1500; i++) msgs.push({ id: i, text: 'm' + i });
|
||||
window.ChannelDecrypt.setCache('ch1', msgs, 12345, 1500);
|
||||
var c = window.ChannelDecrypt.getCache('ch1');
|
||||
return {
|
||||
len: c.messages.length,
|
||||
firstId: c.messages[0].id,
|
||||
lastId: c.messages[c.messages.length - 1].id,
|
||||
lastTs: c.lastTimestamp,
|
||||
count: c.count,
|
||||
};
|
||||
});
|
||||
assert(result.len === 1000, 'expected 1000 stored, got ' + result.len);
|
||||
assert(result.firstId === 500, 'expected first id=500 (last 1000 of 0..1499), got ' + result.firstId);
|
||||
assert(result.lastId === 1499, 'expected last id=1499, got ' + result.lastId);
|
||||
assert(result.lastTs === 12345, 'lastTs: ' + result.lastTs);
|
||||
assert(result.count === 1500, 'count: ' + result.count);
|
||||
});
|
||||
|
||||
await step('cacheMessages / getCachedMessages roundtrip', async () => {
|
||||
const out = await page.evaluate(() => {
|
||||
window.ChannelDecrypt.cacheMessages('hash42', [{ a: 1 }, { a: 2 }]);
|
||||
return window.ChannelDecrypt.getCachedMessages('hash42');
|
||||
});
|
||||
assert(Array.isArray(out) && out.length === 2, 'cache roundtrip failed: ' + JSON.stringify(out));
|
||||
});
|
||||
|
||||
await step('buildKeyMap indexes stored keys by computed hash byte', async () => {
|
||||
const out = await page.evaluate(async () => {
|
||||
window.ChannelDecrypt.saveKey('K1', '00112233445566778899aabbccddeeff');
|
||||
window.ChannelDecrypt.saveKey('K2', 'ffeeddccbbaa99887766554433221100');
|
||||
const map = await window.ChannelDecrypt.buildKeyMap();
|
||||
const entries = [];
|
||||
map.forEach(function (v, k) { entries.push({ hashByte: k, name: v.channelName }); });
|
||||
return entries;
|
||||
});
|
||||
assert(out.length >= 1, 'expected >=1 indexed key, got: ' + JSON.stringify(out));
|
||||
var names = out.map(function (e) { return e.name; });
|
||||
assert(names.indexOf('K1') >= 0 || names.indexOf('K2') >= 0,
|
||||
'expected K1 or K2 in map: ' + JSON.stringify(out));
|
||||
});
|
||||
|
||||
await step('tryDecryptLive returns null for non-GRP_TXT payload', async () => {
|
||||
const out = await page.evaluate(async () => {
|
||||
const map = await window.ChannelDecrypt.buildKeyMap();
|
||||
return await window.ChannelDecrypt.tryDecryptLive(
|
||||
{ type: 'TXT_MSG', encryptedData: 'aa', mac: '0000', channelHash: 0 },
|
||||
map);
|
||||
});
|
||||
assert(out === null, 'expected null, got ' + JSON.stringify(out));
|
||||
});
|
||||
|
||||
await step('tryDecryptLive returns null when no matching hashByte', async () => {
|
||||
const out = await page.evaluate(async () => {
|
||||
const map = await window.ChannelDecrypt.buildKeyMap();
|
||||
return await window.ChannelDecrypt.tryDecryptLive(
|
||||
{ type: 'GRP_TXT', encryptedData: 'aa'.repeat(16), mac: '0000', channelHash: 999 },
|
||||
map);
|
||||
});
|
||||
assert(out === null, 'expected null, got ' + JSON.stringify(out));
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
localStorage.removeItem('corescope_channel_keys');
|
||||
localStorage.removeItem('corescope_channel_labels');
|
||||
localStorage.removeItem('corescope_channel_cache');
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
console.log('\n=== Results: passed ' + passed + ' failed ' + failed + ' ===');
|
||||
await browser.close();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
})().catch((e) => { console.error('FATAL:', e); process.exit(1); });
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* #1297 B2 — Coverage E2E for public/channel-qr.js
|
||||
*
|
||||
* Drives window.ChannelQR in a real browser:
|
||||
* - buildUrl / parseChannelUrl roundtrip + invalid-input rejection
|
||||
* - generate() renders a QR <img> + URL line + Copy Key button via the
|
||||
* vendored qrcode-generator library
|
||||
* - generate() with qrOnly=true skips URL line + Copy button
|
||||
* - Copy Key button copies hex to clipboard (or falls back) and flips
|
||||
* label to "✓ Copied"
|
||||
* - scan() returns null when navigator.mediaDevices is unavailable
|
||||
* (browser-context shim) and shows the inline fallback
|
||||
*
|
||||
* Usage: BASE_URL=http://localhost:13581 node test-channel-qr-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' \u2713 ' + name); }
|
||||
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
// Grant clipboard permissions so navigator.clipboard.writeText succeeds.
|
||||
const ctx = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
|
||||
console.log('\n=== #1297 B2 channel-qr E2E against ' + BASE + ' ===');
|
||||
|
||||
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.ChannelQR && window.ChannelQR.buildUrl,
|
||||
{ timeout: 8000 });
|
||||
|
||||
await step('buildUrl returns meshcore://channel/add?name=...&secret=...', async () => {
|
||||
const url = await page.evaluate(() =>
|
||||
window.ChannelQR.buildUrl('My Room', '00112233445566778899aabbccddeeff'));
|
||||
assert(url.indexOf('meshcore://channel/add?') === 0,
|
||||
'wrong scheme: ' + url);
|
||||
assert(url.indexOf('name=My%20Room') >= 0, 'name not encoded: ' + url);
|
||||
assert(url.indexOf('secret=00112233445566778899aabbccddeeff') >= 0,
|
||||
'secret missing: ' + url);
|
||||
});
|
||||
|
||||
await step('parseChannelUrl returns { name, secret } for valid URL', async () => {
|
||||
const out = await page.evaluate(() =>
|
||||
window.ChannelQR.parseChannelUrl(
|
||||
'meshcore://channel/add?name=My%20Room&secret=00112233445566778899AABBCCDDEEFF'));
|
||||
assert(out && out.name === 'My Room', 'name: ' + (out && out.name));
|
||||
// secret should be lowercased
|
||||
assert(out && out.secret === '00112233445566778899aabbccddeeff',
|
||||
'secret: ' + (out && out.secret));
|
||||
});
|
||||
|
||||
await step('parseChannelUrl rejects wrong scheme', async () => {
|
||||
const out = await page.evaluate(() =>
|
||||
window.ChannelQR.parseChannelUrl('https://example.com?name=x&secret=' + 'a'.repeat(32)));
|
||||
assert(out === null, 'expected null, got ' + JSON.stringify(out));
|
||||
});
|
||||
|
||||
await step('parseChannelUrl rejects missing secret', async () => {
|
||||
const out = await page.evaluate(() =>
|
||||
window.ChannelQR.parseChannelUrl('meshcore://channel/add?name=onlyname'));
|
||||
assert(out === null, 'expected null, got ' + JSON.stringify(out));
|
||||
});
|
||||
|
||||
await step('parseChannelUrl rejects non-32-hex secret', async () => {
|
||||
const out = await page.evaluate(() =>
|
||||
window.ChannelQR.parseChannelUrl(
|
||||
'meshcore://channel/add?name=x&secret=zznothex'));
|
||||
assert(out === null, 'expected null, got ' + JSON.stringify(out));
|
||||
});
|
||||
|
||||
await step('parseChannelUrl rejects null/empty/non-string', async () => {
|
||||
const out = await page.evaluate(() => {
|
||||
return {
|
||||
a: window.ChannelQR.parseChannelUrl(null),
|
||||
b: window.ChannelQR.parseChannelUrl(''),
|
||||
c: window.ChannelQR.parseChannelUrl(42),
|
||||
};
|
||||
});
|
||||
assert(out.a === null && out.b === null && out.c === null,
|
||||
'expected nulls, got ' + JSON.stringify(out));
|
||||
});
|
||||
|
||||
await step('generate() renders QR <img> + URL line + Copy Key button', async () => {
|
||||
const info = await page.evaluate(() => {
|
||||
const t = document.createElement('div');
|
||||
t.id = '__qrTest1';
|
||||
document.body.appendChild(t);
|
||||
window.ChannelQR.generate('My Room', '00112233445566778899aabbccddeeff', t);
|
||||
return {
|
||||
canvasHtml: t.querySelector('.channel-qr-canvas') ?
|
||||
t.querySelector('.channel-qr-canvas').innerHTML.slice(0, 200) : null,
|
||||
hasImg: !!t.querySelector('.channel-qr-canvas img'),
|
||||
urlText: t.querySelector('.channel-qr-url') ?
|
||||
t.querySelector('.channel-qr-url').textContent : null,
|
||||
copyBtnText: t.querySelector('.channel-qr-copy') ?
|
||||
t.querySelector('.channel-qr-copy').textContent : null,
|
||||
};
|
||||
});
|
||||
assert(info.hasImg, 'expected <img> in .channel-qr-canvas, got: ' + info.canvasHtml);
|
||||
assert(info.urlText && info.urlText.indexOf('meshcore://channel/add') === 0,
|
||||
'URL line wrong: ' + info.urlText);
|
||||
assert(info.copyBtnText && info.copyBtnText.indexOf('Copy Key') >= 0,
|
||||
'copy btn text: ' + info.copyBtnText);
|
||||
});
|
||||
|
||||
await step('generate() with qrOnly skips URL line + Copy Key', async () => {
|
||||
const info = await page.evaluate(() => {
|
||||
const t = document.createElement('div');
|
||||
t.id = '__qrTest2';
|
||||
document.body.appendChild(t);
|
||||
window.ChannelQR.generate('Solo', 'aabbccddeeff00112233445566778899', t,
|
||||
{ qrOnly: true });
|
||||
return {
|
||||
hasImg: !!t.querySelector('.channel-qr-canvas img'),
|
||||
hasUrlLine: !!t.querySelector('.channel-qr-url'),
|
||||
hasCopy: !!t.querySelector('.channel-qr-copy'),
|
||||
};
|
||||
});
|
||||
assert(info.hasImg, 'expected QR <img>');
|
||||
assert(!info.hasUrlLine, 'qrOnly should skip URL line');
|
||||
assert(!info.hasCopy, 'qrOnly should skip Copy Key button');
|
||||
});
|
||||
|
||||
await step('Copy Key button writes hex to clipboard + flips label', async () => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const t = document.createElement('div');
|
||||
t.id = '__qrTest3';
|
||||
document.body.appendChild(t);
|
||||
const hex = 'deadbeefcafef00d0011223344556677';
|
||||
window.ChannelQR.generate('Copyable', hex, t);
|
||||
const btn = t.querySelector('.channel-qr-copy');
|
||||
btn.click();
|
||||
// Wait a tick for the async copy to complete + label flip
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
let clip = '';
|
||||
try {
|
||||
clip = await navigator.clipboard.readText();
|
||||
} catch (_e) { clip = '(read-denied)'; }
|
||||
return { clip: clip, btnText: btn.textContent, expected: hex };
|
||||
});
|
||||
assert(result.btnText.indexOf('Copied') >= 0,
|
||||
'expected "Copied" label, got: ' + result.btnText);
|
||||
// Allow read-denied in some headless environments — primary assertion
|
||||
// is the visible label flip. When we CAN read clipboard, it should
|
||||
// contain the hex.
|
||||
if (result.clip !== '(read-denied)') {
|
||||
assert(result.clip === result.expected,
|
||||
'clipboard mismatch: got "' + result.clip + '" expected "' + result.expected + '"');
|
||||
}
|
||||
});
|
||||
|
||||
await step('generate() is a no-op without target', async () => {
|
||||
const err = await page.evaluate(() => {
|
||||
try {
|
||||
window.ChannelQR.generate('x', 'aa', null);
|
||||
return null;
|
||||
} catch (e) { return e.message; }
|
||||
});
|
||||
assert(err === null, 'expected silent no-op, got: ' + err);
|
||||
});
|
||||
|
||||
await step('scan() resolves with null when getUserMedia is unavailable', async () => {
|
||||
const result = await page.evaluate(async () => {
|
||||
// Shim mediaDevices off for this call. We can't undefine
|
||||
// navigator.mediaDevices directly in Chromium, so override its
|
||||
// getUserMedia to throw and clear jsQR.
|
||||
const savedJsqr = window.jsQR;
|
||||
window.jsQR = undefined;
|
||||
const out = await window.ChannelQR.scan();
|
||||
window.jsQR = savedJsqr;
|
||||
const fallback = document.querySelector('.channel-qr-fallback');
|
||||
return {
|
||||
out: out,
|
||||
fallbackText: fallback ? fallback.textContent : null,
|
||||
};
|
||||
});
|
||||
assert(result.out === null, 'expected null result, got ' + JSON.stringify(result.out));
|
||||
assert(result.fallbackText && /Camera not available/i.test(result.fallbackText),
|
||||
'expected fallback toast, got: ' + result.fallbackText);
|
||||
});
|
||||
|
||||
console.log('\n=== Results: passed ' + passed + ' failed ' + failed + ' ===');
|
||||
await browser.close();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
})().catch((e) => { console.error('FATAL:', e); process.exit(1); });
|
||||
Reference in New Issue
Block a user