Files
meshcore-analyzer/test-channel-psk-ux.js
T
Kpa-clawbot cea2c70d12 feat(#1034): channel UX redesign PR1 — Add Channel modal + sectioned sidebar (#1037)
## Summary

PR 1 of 3 for #1034 — channel UX redesign. Replaces the cramped inline
"type a name or 32-hex blob" form with a clear modal dialog, and
reorganizes the sidebar into three labeled sections.

**Scope of this PR:** Modal UI + sectioned sidebar. QR generation/scan
is deferred to PR #2 (placeholders are wired and ready).
`channel-decrypt.js` crypto is untouched.

## What changed

### New modal: `[+ Add Channel]`

Triggered by the new sidebar button. Three sections:

1. **Generate PSK Channel** — name + `[Generate & Show QR]` →
`crypto.getRandomValues(16)` → hex → `ChannelDecrypt.storeKey`. QR
rendering ships in PR #2; for now `#qr-output` surfaces the hex key as
text.
2. **Add Private Channel (PSK)** — 32-hex input (regex-validated),
optional display name, `[Add]`. `[📷 Scan QR]` placeholder is present but
`disabled` (PR #2 wires it).
3. **Monitor Hashtag Channel** — non-editable `#` prefix + free text +
case-sensitivity warning + `[Monitor]`. Reuses
`ChannelDecrypt.deriveKey`.

Privacy footer: _"🔒 Keys stay in your browser. CoreScope is a passive
observer..."_

Close ✕, backdrop click, and Escape all dismiss.

### Sectioned sidebar

`renderChannelList()` rewritten to render three sections:

- **My Channels** — `userAdded` channels. ✕ always visible. Last sender
+ relative time.
- **Network** — server-known cleartext channels.
- **Encrypted (N)** — collapsed by default (toggle persists in
`localStorage`). Shows hash byte + packet count.

The legacy "🔒 No key" checkbox and `#chShowEncrypted` toggle are removed
entirely. Encrypted channels are always fetched; the renderer groups
them.

## Tests

- **Unit** — `test-channel-modal-ux.js` (33 assertions): added to
`test-all.sh`. Covers sidebar button, modal markup, three sections, QR
placeholders, privacy footer, sectioned sidebar, modal handlers (incl.
`crypto.getRandomValues(16)`).
- **E2E** — `test-channel-modal-e2e.js` (Playwright, 14 steps). Covers
modal open/close, section rendering, invalid-hex error, valid-hex
storage, encrypted-section toggle. Run with:
  ```
CHROMIUM_PATH=/usr/bin/chromium-browser BASE_URL=http://localhost:38201
node test-channel-modal-e2e.js
  ```
- `test-channel-psk-ux.js` — updated to reference `#chPskName` (was
`#chKeyLabelInput`).

### Red→green proof

- Red commit (`7ee421b`): test added with 31 expected assertion
failures, no source change.
- Green commit (`897be8f`): implementation lands, test passes 33/33.

## Browser-validated

Built `cmd/server/`, ran against `test-fixtures/e2e-fixture.db`,
exercised modal open → invalid hex → valid hex → key persisted → modal
closes → sectioned sidebar renders + Encrypted toggle expands. All 14
E2E steps pass.

## What's NOT in this PR

- QR code rendering (PR #2)
- Camera/QR scanning (PR #2)
- Migration of legacy localStorage format (PR #3, if needed — current
key format is unchanged)
- `channel-decrypt.js` changes (none — UI-only PR)

## Acceptance criteria from #1034

- [x] Modal opens on `[+ Add Channel]` click
- [x] Three sections clearly separated with labels
- [x] Add PSK: accepts 32-hex (QR scan = PR #2)
- [x] Monitor Hashtag: derives key, case-sensitivity warning shown
- [x] Privacy footer present
- [x] Sidebar: three sections (My Channels / Network / Encrypted)
- [x] ✕ button visible and functional on My Channels entries
- [x] "No key" checkbox removed
- [ ] Generate PSK QR display — text fallback only; QR is PR #2
- [ ] Old stored keys migrate seamlessly — no migration needed (storage
format unchanged)

Refs #1034

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-04 18:40:46 -07:00

158 lines
6.4 KiB
JavaScript

/**
* Tests for #1020 — PSK channel UX:
* - Optional label stored alongside key in localStorage
* - removeKey clears both key and label
* - channels.js form has an optional label input
* - User-added rows render with a distinct badge marker in the DOM
* - Status feedback reports decrypt count from result (not DOM scrape)
*
* Runs in Node.js via vm.createContext to simulate the browser.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const { subtle } = require('crypto').webcrypto;
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
function createSandbox() {
const storage = {};
const localStorage = {
getItem: (k) => storage[k] !== undefined ? storage[k] : null,
setItem: (k, v) => { storage[k] = String(v); },
removeItem: (k) => { delete storage[k]; },
_data: storage,
};
const ctx = {
window: {},
crypto: { subtle },
TextEncoder, TextDecoder, Uint8Array,
localStorage,
console, Date, JSON, parseInt, Math, String, Number, Object, Array, RegExp, Error, Promise, setTimeout,
btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
atob: (s) => Buffer.from(s, 'base64').toString('binary'),
};
ctx.window = ctx;
ctx.self = ctx;
return ctx;
}
async function run() {
console.log('\n=== #1020 PSK UX: ChannelDecrypt label storage ===');
const cdSrc = fs.readFileSync(path.join(__dirname, 'public/channel-decrypt.js'), 'utf8');
const sandbox = createSandbox();
vm.runInContext(cdSrc, vm.createContext(sandbox));
const CD = sandbox.window.ChannelDecrypt;
// saveLabel/getLabel API exists
assert(typeof CD.saveLabel === 'function', 'ChannelDecrypt.saveLabel exists');
assert(typeof CD.getLabel === 'function', 'ChannelDecrypt.getLabel exists');
assert(typeof CD.getLabels === 'function', 'ChannelDecrypt.getLabels exists');
// saveKey overload with label argument
CD.storeKey('psk:aabbccdd', 'aabbccdd11223344aabbccdd11223344', 'My Secret Channel');
assert(CD.getLabel('psk:aabbccdd') === 'My Secret Channel',
'storeKey(name, hex, label) persists label retrievable via getLabel');
// saveLabel updates an existing key's label
CD.saveLabel('psk:aabbccdd', 'Renamed');
assert(CD.getLabel('psk:aabbccdd') === 'Renamed', 'saveLabel updates label');
// removeKey clears label too
CD.removeKey('psk:aabbccdd');
assert(!CD.getLabel('psk:aabbccdd'), 'removeKey clears stored label');
// No-label storage stays valid
CD.storeKey('#LongFast', 'deadbeefdeadbeefdeadbeefdeadbeef');
const keys = CD.getStoredKeys();
assert(keys['#LongFast'] === 'deadbeefdeadbeefdeadbeefdeadbeef',
'storeKey without label still persists key');
assert(!CD.getLabel('#LongFast'), 'no label means getLabel returns falsy');
console.log('\n=== #1020 PSK UX: channels.js DOM/contract ===');
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
// E2E DOM: optional label input in add form (now in #1034 modal as #chPskName)
assert(chSrc.includes('id="chPskName"') || chSrc.includes('id="chKeyLabelInput"'),
'add form contains optional label input (#chPskName in modal, was #chKeyLabelInput)');
assert(/placeholder="[^"]*name[^"]*"/i.test(chSrc) || chSrc.includes('chPskName') || chSrc.includes('chKeyLabelInput'),
'label input has a name-related placeholder');
// E2E DOM: distinct badge class/marker for user-added channels
assert(chSrc.includes('ch-user-added'),
'renderChannelList emits ch-user-added marker for keyed channels');
// Distinct icon
assert(chSrc.includes('🔓'),
'user-added rows use a distinct unlocked icon (🔓) from server-encrypted (🔒)');
// addUserChannel accepts label
assert(/addUserChannel\s*\(\s*val\s*,\s*\w*label/i.test(chSrc) ||
/addUserChannel\([^)]*\blabel\b[^)]*\)/.test(chSrc),
'addUserChannel signature accepts a label parameter');
// mergeUserChannels reads labels
assert(/getLabels?\s*\(/.test(chSrc),
'channels.js queries ChannelDecrypt.getLabels()/getLabel()');
// Toast count comes from result.messages, not from #chMessages DOM scrape
assert(!/querySelectorAll\('#chMessages \.ch-msg'\)\.length/.test(chSrc),
'addUserChannel must not scrape #chMessages DOM for count (use decrypt result)');
console.log('\n=== #1020 PSK UX: end-to-end label flow via mergeUserChannels ===');
// Reset sandbox storage and re-run the module so the userLabel propagation
// through mergeUserChannels is exercised end-to-end (not just by string-grep).
const sandbox2 = createSandbox();
vm.runInContext(cdSrc, vm.createContext(sandbox2));
const CD2 = sandbox2.window.ChannelDecrypt;
CD2.storeKey('psk:cafebabe', 'cafebabecafebabecafebabecafebabe', 'Crew Channel');
CD2.storeKey('#NoLabel', 'deadbeefdeadbeefdeadbeefdeadbeef');
// Lift the IIFE-internal mergeUserChannels behavior into a tiny harness:
// simulate the relevant slice of channels.js using the public API.
const channelsArr = [];
function mergeUserChannels(channels, CDref) {
const keys = CDref.getStoredKeys();
const labels = CDref.getLabels();
Object.keys(keys).forEach(name => {
const label = labels[name] || '';
const existing = channels.find(c => c.name === name || c.hash === name || c.hash === ('user:' + name));
if (existing) {
existing.userAdded = true;
if (label) existing.userLabel = label;
} else {
channels.push({
hash: 'user:' + name, name, userLabel: label,
messageCount: 0, encrypted: true, userAdded: true,
});
}
});
}
mergeUserChannels(channelsArr, CD2);
const labeled = channelsArr.find(c => c.name === 'psk:cafebabe');
const unlabeled = channelsArr.find(c => c.name === '#NoLabel');
assert(labeled && labeled.userLabel === 'Crew Channel',
'mergeUserChannels propagates user label onto channel object');
assert(unlabeled && unlabeled.userAdded === true && !unlabeled.userLabel,
'mergeUserChannels marks unlabeled channels userAdded with no label');
// Removal path clears both
CD2.removeKey('psk:cafebabe');
assert(!CD2.getStoredKeys()['psk:cafebabe'], 'after removeKey, key gone');
assert(!CD2.getLabel('psk:cafebabe'), 'after removeKey, label gone');
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);
process.exit(failed > 0 ? 1 : 0);
}
run().catch((e) => { console.error(e); process.exit(1); });