test: cover cryptoDiagLog + closedLobbyCategories
Test-coverage batch 2 (2-agent reviewed, both SHIP; isolation verified — Node runs each test file in its own process, so the console patch can't leak). - cryptoDiagLog.test.ts: the E2EE KE-cluster diagnostics tool — KE-signature capture vs ignore, most-specific-first match order, KE-3/KE-4 rows, Error / object / circular-arg serialization (String() fallback never throws), the 200-entry ring-buffer eviction, getCryptoDiagEntries copy semantics, install idempotency, and buildCryptoDiagReport's client metadata + LOCKED PII-safe key set (no field can silently leak) + no-client/throwing-getter fallbacks. Silences console pass-through so the ring-buffer test stays quiet. - closedLobbyCategories.test.ts: mirrors closedNavCategories — id join, hydrate, PUT/DELETE, idempotent PUT, no-op DELETE, array persistence, per-user key namespacing. Also: mark the EC in-call mobile UI audit done in LOTUS_TODO (stale entry; shipped as element-call:lotus e36aef8a). Gates: tsc 0, eslint 0, prettier clean, 911 tests, build ok. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -212,7 +212,7 @@ Intentional desktop deltas (disclosed, non-regressive): volume sliders below lab
|
||||
**NOT done — needs a real device / product decisions (open):**
|
||||
|
||||
- [ ] **Runtime mobile QA** — none of the above is validated on an actual phone (static analysis only). Needs device/devtools walk-through per LOTUS_TESTING §E.
|
||||
- [ ] **Element Call fork in-call mobile UI** — the EC iframe (video grid, EC control bar, spotlight) is `LotusGuild/element-call` at `/root/code/element-call` (we own it); the cinny-side audit couldn't reach it. The actual on-phone call experience is unaudited.
|
||||
- [x] **Element Call fork in-call mobile UI** — DONE (`element-call:lotus` `e36aef8a`, 3-agent survey + 2-agent review). Fixed the EC iframe's own phone UI: footer control row wraps so hangup can't clip (320–500px), portrait 1:1 self-PiP safe-area inset, 44px camera-flip + reaction-picker targets, settings-tab horizontal scroll, landscape spotlight filmstrip. All mobile-gated (EC is mobile-first CSS). Rides to users on the next fork republish (P6-2). Runtime on-device QA still pending (needs a phone).
|
||||
- [ ] **M2 — touch discoverability** — message quick-reactions/actions are hover-gated; long-press is the fallback but is **unreliable on iOS Safari** (deep audit). A visible touch affordance is needed but the naive fix hides unread badges / clutters messages (member-profile-style redesign).
|
||||
- [~] **Sub-44px touch-target sweep** — primary controls DONE via a shared `MobileTouchTarget` `@media` class (`P1`, `8a1168bc`): in-call bar ×7, call-status bar ×4, thread "N replies" chip, knock Approve/Deny, ACL remove. Secondary batch DONE (`r2`, `72e7447d`): image-viewer close/zoom±/zoom%/download, embed-player Close/Collapse/Fullscreen/View-post, read-receipt "seen by" pill. **Deferred (rationale, not built):** PiP fullscreen/resize handles — enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and block "Return to call" (needs a design rethink, not a blunt bump); presence dot is a non-interactive status indicator (no target needed).
|
||||
- [x] **Avatar-decoration `prefers-reduced-motion`** — DONE (`P2`, `c3e1fbff`): renders just the avatar (no animated APNG overlay) under the preference; no static-frame asset to freeze to.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { enableMapSet } from 'immer';
|
||||
import { makeClosedLobbyCategoriesAtom, makeLobbyCategoryId } from './closedLobbyCategories';
|
||||
|
||||
// makeClosedLobbyCategoriesAtom(userId) builds a Set<string> atom whose reducer uses
|
||||
// immer produce (PUT add / DELETE delete) and persists to a per-user localStorage
|
||||
// key `closedLobbyCategories<userId>`. The reducers produce over a Set, so enable
|
||||
// immer's Map/Set plugin (the app does this once at startup).
|
||||
// makeLobbyCategoryId joins args with '|'. (Mirrors closedNavCategories.test.ts.)
|
||||
enableMapSet();
|
||||
|
||||
type Store = Record<string, string>;
|
||||
const installLocalStorage = (): Store => {
|
||||
const data: Store = {};
|
||||
const ls = {
|
||||
getItem: (k: string) => (k in data ? data[k] : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
data[k] = String(v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
delete data[k];
|
||||
},
|
||||
};
|
||||
(globalThis as { localStorage?: unknown }).localStorage = ls;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
test('makeLobbyCategoryId joins args with "|"', () => {
|
||||
assert.equal(makeLobbyCategoryId('space', 'catA'), 'space|catA');
|
||||
assert.equal(makeLobbyCategoryId('only'), 'only');
|
||||
assert.equal(makeLobbyCategoryId(), '');
|
||||
});
|
||||
|
||||
test('starts empty when nothing is stored', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
|
||||
assert.equal(store.get(lobbyAtom).size, 0);
|
||||
});
|
||||
|
||||
test('hydrates the Set from the per-user localStorage key', () => {
|
||||
const data = installLocalStorage();
|
||||
data['closedLobbyCategories@u:server'] = JSON.stringify(['x', 'y']);
|
||||
const store = createStore();
|
||||
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
|
||||
assert.deepEqual(Array.from(store.get(lobbyAtom)).sort(), ['x', 'y']);
|
||||
});
|
||||
|
||||
test('PUT adds a category and DELETE removes it', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
|
||||
|
||||
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
assert.deepEqual(Array.from(store.get(lobbyAtom)), ['cat1']);
|
||||
|
||||
store.set(lobbyAtom, { type: 'DELETE', categoryId: 'cat1' });
|
||||
assert.equal(store.get(lobbyAtom).has('cat1'), false);
|
||||
});
|
||||
|
||||
test('PUT of an existing category is idempotent', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
|
||||
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
assert.equal(store.get(lobbyAtom).size, 1);
|
||||
});
|
||||
|
||||
test('DELETE of an absent category is a no-op', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
|
||||
store.set(lobbyAtom, { type: 'DELETE', categoryId: 'missing' });
|
||||
assert.equal(store.get(lobbyAtom).size, 0);
|
||||
});
|
||||
|
||||
test('writes persist to localStorage as an array', () => {
|
||||
const data = installLocalStorage();
|
||||
const store = createStore();
|
||||
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
|
||||
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
assert.deepEqual(JSON.parse(data['closedLobbyCategories@u:server']), ['cat1']);
|
||||
});
|
||||
|
||||
test('the storage key is namespaced per user', () => {
|
||||
const data = installLocalStorage();
|
||||
const store = createStore();
|
||||
const aAtom = makeClosedLobbyCategoriesAtom('@a:server');
|
||||
const bAtom = makeClosedLobbyCategoriesAtom('@b:server');
|
||||
|
||||
store.set(aAtom, { type: 'PUT', categoryId: 'only-a' });
|
||||
assert.deepEqual(JSON.parse(data['closedLobbyCategories@a:server']), ['only-a']);
|
||||
assert.equal(data['closedLobbyCategories@b:server'], undefined);
|
||||
assert.equal(store.get(bAtom).size, 0);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { MatrixClient } from 'matrix-js-sdk';
|
||||
import { buildCryptoDiagReport, getCryptoDiagEntries, installCryptoDiagLog } from './cryptoDiagLog';
|
||||
|
||||
// installCryptoDiagLog() replaces console.warn/error with capturing wrappers that
|
||||
// ALWAYS pass through to the original. Silence the originals here (before install
|
||||
// captures them) so the ring-buffer test doesn't spam hundreds of lines into the
|
||||
// test output — the capture logic itself still runs. The module's `entries` buffer
|
||||
// is module-global and accumulates across tests in this file, so assertions use
|
||||
// deltas or the absolute cap rather than assuming an empty buffer.
|
||||
console.warn = () => undefined;
|
||||
console.error = () => undefined;
|
||||
installCryptoDiagLog();
|
||||
|
||||
const mockClient = (partial: Partial<Record<string, unknown>>): MatrixClient =>
|
||||
partial as unknown as MatrixClient;
|
||||
|
||||
test('captures a KE-signature line and ignores non-matching output', () => {
|
||||
const before = getCryptoDiagEntries().length;
|
||||
console.error('POST /keys/upload 400 M_UNKNOWN: One time key already exists');
|
||||
console.warn('just a normal warning with nothing to capture');
|
||||
const after = getCryptoDiagEntries();
|
||||
assert.equal(after.length, before + 1, 'only the matching line is captured');
|
||||
const last = after[after.length - 1];
|
||||
assert.equal(last.ke, 'KE-1');
|
||||
assert.equal(last.signature, 'already exists');
|
||||
assert.equal(last.level, 'error');
|
||||
assert.match(last.message, /already exists/);
|
||||
assert.match(last.ts, /^\d{4}-\d\d-\d\dT/); // ISO-8601 UTC
|
||||
});
|
||||
|
||||
test('matches the first (tightest) signature when several apply', () => {
|
||||
const before = getCryptoDiagEntries().length;
|
||||
// Matches both KE-1 'already exists' (index 0) and KE-2 'MissingKey' (index 3);
|
||||
// find() returns the first, so the tightest/most-specific label is recorded.
|
||||
console.error('MissingKey: the session key already exists somehow');
|
||||
const e = getCryptoDiagEntries();
|
||||
assert.equal(e.length, before + 1);
|
||||
assert.equal(e[e.length - 1].ke, 'KE-1');
|
||||
assert.equal(e[e.length - 1].signature, 'already exists');
|
||||
});
|
||||
|
||||
test('serializes Error and object args into the captured message', () => {
|
||||
const b1 = getCryptoDiagEntries().length;
|
||||
console.error(new Error('boom: io.element.call.encryption_keys arrived encrypted'));
|
||||
const afterErr = getCryptoDiagEntries();
|
||||
assert.equal(afterErr.length, b1 + 1);
|
||||
assert.match(afterErr[afterErr.length - 1].message, /^Error: boom/);
|
||||
assert.equal(afterErr[afterErr.length - 1].ke, 'KE-2');
|
||||
|
||||
const b2 = getCryptoDiagEntries().length;
|
||||
console.warn('missing key at index', { index: 7 });
|
||||
const afterObj = getCryptoDiagEntries();
|
||||
assert.equal(afterObj.length, b2 + 1);
|
||||
assert.match(afterObj[afterObj.length - 1].message, /\{"index":7\}/);
|
||||
assert.equal(afterObj[afterObj.length - 1].ke, 'KE-2');
|
||||
});
|
||||
|
||||
test('ring-buffers to at most 200 entries, evicting the oldest', () => {
|
||||
// Push well past the cap with uniquely-tagged KE-4 lines (matches /delayed event/i).
|
||||
for (let i = 0; i < 250; i += 1) {
|
||||
console.warn(`delayed event tag=${i};`);
|
||||
}
|
||||
const e = getCryptoDiagEntries();
|
||||
assert.equal(e.length, 200, 'buffer is capped at MAX_ENTRIES');
|
||||
assert.match(e[e.length - 1].message, /tag=249;/, 'newest is retained');
|
||||
assert.ok(!e.some((x) => x.message.includes('tag=0;')), 'the oldest pushes were evicted');
|
||||
});
|
||||
|
||||
test('getCryptoDiagEntries returns a copy, not the live buffer', () => {
|
||||
const a = getCryptoDiagEntries();
|
||||
const b = getCryptoDiagEntries();
|
||||
assert.notEqual(a, b, 'each call returns a fresh array');
|
||||
const len = a.length;
|
||||
a.push({ ts: 'x', level: 'warn', ke: 'X', signature: 'x', message: 'x' });
|
||||
assert.equal(getCryptoDiagEntries().length, len, 'mutating the copy does not affect the buffer');
|
||||
});
|
||||
|
||||
// NOTE: these run after the ring-buffer test has filled the buffer to its cap,
|
||||
// so a capture evicts the oldest and length stays at 200 — assert on the NEWEST
|
||||
// entry (always the line just pushed) rather than a length delta.
|
||||
test('captures the KE-3 and KE-4 signatures', () => {
|
||||
console.error('DecryptionError: unable to decrypt event');
|
||||
const e1 = getCryptoDiagEntries();
|
||||
assert.equal(e1[e1.length - 1].ke, 'KE-3');
|
||||
assert.equal(e1[e1.length - 1].signature, 'DecryptionError');
|
||||
|
||||
// underscore, not a space, so it matches the tighter 'update_delayed_event'
|
||||
// row (index 5) rather than the looser 'delayed event' row.
|
||||
console.warn('msc4157.update_delayed_event timed out');
|
||||
const e2 = getCryptoDiagEntries();
|
||||
assert.equal(e2[e2.length - 1].ke, 'KE-4');
|
||||
assert.equal(e2[e2.length - 1].signature, 'update_delayed_event');
|
||||
});
|
||||
|
||||
test('serializes an unserializable (circular) arg via the String() fallback without throwing', () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular; // JSON.stringify throws → stringifyArg falls back to String()
|
||||
// Pair with a matching string so the line is captured; the object exercises
|
||||
// the catch branch. This must not throw.
|
||||
console.error('DecryptionError from', circular);
|
||||
const e = getCryptoDiagEntries();
|
||||
assert.equal(e[e.length - 1].ke, 'KE-3');
|
||||
assert.match(e[e.length - 1].message, /\[object Object\]/);
|
||||
});
|
||||
|
||||
test('installCryptoDiagLog is idempotent — a second call does not re-wrap console', () => {
|
||||
const wrappedWarn = console.warn;
|
||||
installCryptoDiagLog(); // guarded no-op
|
||||
// Reference unchanged ⇒ not re-wrapped ⇒ a line is captured once, not doubled.
|
||||
assert.equal(console.warn, wrappedWarn, 'console.warn is not re-wrapped');
|
||||
console.error('DecryptionError single-capture check');
|
||||
const e = getCryptoDiagEntries();
|
||||
assert.match(e[e.length - 1].message, /single-capture check/, 'still captured');
|
||||
});
|
||||
|
||||
test('buildCryptoDiagReport captures client metadata in a fixed, PII-safe shape', () => {
|
||||
const mx = mockClient({
|
||||
getDeviceId: () => 'DEVICE123',
|
||||
getUserId: () => '@alice:example.org',
|
||||
getSyncState: () => 'SYNCING',
|
||||
getCrypto: () => ({}),
|
||||
getSdkVersion: () => '99.9.9',
|
||||
});
|
||||
const report = JSON.parse(buildCryptoDiagReport(mx));
|
||||
|
||||
assert.equal(report.kind, 'lotus-crypto-diag');
|
||||
assert.equal(report.deviceId, 'DEVICE123');
|
||||
assert.equal(report.userId, '@alice:example.org');
|
||||
assert.equal(report.syncState, 'SYNCING');
|
||||
assert.equal(report.cryptoReady, true);
|
||||
assert.equal(report.sdkVersion, '99.9.9');
|
||||
assert.equal(report.maxEntries, 200);
|
||||
assert.equal(report.entryCount, getCryptoDiagEntries().length);
|
||||
|
||||
const sum = Object.values<number>(report.countsByKe).reduce((a, b) => a + b, 0);
|
||||
assert.equal(sum, report.entryCount, 'countsByKe sums to entryCount');
|
||||
|
||||
// Locked field set: nothing beyond these documented keys ever leaks.
|
||||
assert.deepEqual(Object.keys(report).sort(), [
|
||||
'countsByKe',
|
||||
'cryptoReady',
|
||||
'deviceId',
|
||||
'entries',
|
||||
'entryCount',
|
||||
'generatedAt',
|
||||
'kind',
|
||||
'maxEntries',
|
||||
'sdkVersion',
|
||||
'syncState',
|
||||
'userId',
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildCryptoDiagReport degrades gracefully with no client', () => {
|
||||
const report = JSON.parse(buildCryptoDiagReport());
|
||||
assert.equal(report.deviceId, null);
|
||||
assert.equal(report.userId, null);
|
||||
assert.equal(report.syncState, null);
|
||||
assert.equal(report.cryptoReady, false);
|
||||
// sdkVersion falls back to the declared package.json pin — a version string.
|
||||
assert.equal(typeof report.sdkVersion, 'string');
|
||||
assert.ok(report.sdkVersion.length > 0);
|
||||
});
|
||||
|
||||
test('sdkVersion falls back to the declared pin when the client getter throws', () => {
|
||||
const mx = mockClient({
|
||||
getDeviceId: () => null,
|
||||
getUserId: () => null,
|
||||
getSyncState: () => null,
|
||||
getCrypto: () => undefined,
|
||||
getSdkVersion: () => {
|
||||
throw new Error('not ready');
|
||||
},
|
||||
});
|
||||
const report = JSON.parse(buildCryptoDiagReport(mx));
|
||||
assert.equal(typeof report.sdkVersion, 'string');
|
||||
assert.ok(report.sdkVersion.length > 0);
|
||||
});
|
||||
Reference in New Issue
Block a user