/** * #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 + 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 + 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 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 '); 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); });