mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 04:21:20 +00:00
791c8ae1bc
Red commit: ae8838ef (CI: pending — see Checks tab once attached)
## What
Channels page mobile UX overhaul (#1367). Restores prod's chat-app row
layout, drops the analytics chip, and adds a per-channel detail view.
## Status
Draft — RED commit on the wire. Greens will follow in subsequent commits
before this is moved to Ready.
Fixes #1367
---------
Co-authored-by: bot <bot@example.com>
250 lines
12 KiB
JavaScript
250 lines
12 KiB
JavaScript
/**
|
|
* E2E (#1367): Channels page chat-app redesign — restore prod's row layout,
|
|
* drop the analytics chip, and add a per-channel detail view.
|
|
*
|
|
* Design source: issue #1367 body + 4 design-lock comments
|
|
* (Operator + Tufte): full-width chat-app rows with avatar / name /
|
|
* preview / relative-time; no inline action chips on rows; tap a row
|
|
* to slide into a full-screen messages view; back chevron + title.
|
|
*
|
|
* Run: BASE_URL=http://localhost:13581 node test-issue-1367-channels-chat-app-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 function run() {
|
|
const launchOpts = { args: ['--no-sandbox'] };
|
|
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
|
|
const browser = await chromium.launch(launchOpts);
|
|
|
|
// ----- Mobile (375x800) -----
|
|
const ctx = await browser.newContext({ viewport: { width: 375, height: 800 } });
|
|
const page = await ctx.newPage();
|
|
|
|
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#chList', { timeout: 10000 });
|
|
// New rows use .ch-row; wait for at least one to render.
|
|
await page.waitForFunction(() => {
|
|
const l = document.getElementById('chList');
|
|
return l && l.querySelectorAll('.ch-row').length > 0;
|
|
}, { timeout: 15000 });
|
|
await page.waitForTimeout(200);
|
|
|
|
await step('channel rows use .ch-row, are ~80px tall, full-width', async () => {
|
|
const data = await page.evaluate(() => {
|
|
const rows = document.querySelectorAll('#chList .ch-row');
|
|
if (!rows.length) return null;
|
|
const r = rows[0];
|
|
const rect = r.getBoundingClientRect();
|
|
const parentW = r.parentElement.getBoundingClientRect().width;
|
|
return { h: Math.round(rect.height), w: Math.round(rect.width), parentW: Math.round(parentW), count: rows.length };
|
|
});
|
|
assert(data, 'no .ch-row elements found');
|
|
assert(data.h >= 72 && data.h <= 88, '.ch-row height must be 72-88px, got ' + data.h);
|
|
// Full-width within its list container (allow 4px slop for borders/padding).
|
|
assert(data.w >= data.parentW - 8, '.ch-row width ' + data.w + ' must fill parent ' + data.parentW);
|
|
});
|
|
|
|
await step('each row has .ch-avatar with hash-derived bg + 2-3 char text', async () => {
|
|
const info = await page.evaluate(() => {
|
|
const row = document.querySelector('#chList .ch-row');
|
|
const av = row && row.querySelector('.ch-avatar');
|
|
if (!av) return null;
|
|
const bg = getComputedStyle(av).backgroundColor;
|
|
return { text: (av.textContent || '').trim(), bg: bg };
|
|
});
|
|
assert(info, 'first row has no .ch-avatar');
|
|
assert(info.text.length >= 1 && info.text.length <= 3, 'avatar text length must be 1-3, got "' + info.text + '"');
|
|
// Background should be a real color, not transparent / none.
|
|
assert(info.bg && info.bg !== 'rgba(0, 0, 0, 0)' && info.bg !== 'transparent',
|
|
'avatar bg must be a real color, got ' + info.bg);
|
|
});
|
|
|
|
await step('row body has bold name, preview text, right-aligned timestamp', async () => {
|
|
const data = await page.evaluate(() => {
|
|
const row = document.querySelector('#chList .ch-row');
|
|
const name = row && row.querySelector('.ch-row-name');
|
|
const prev = row && row.querySelector('.ch-row-preview');
|
|
const time = row && row.querySelector('.ch-row-time');
|
|
if (!name || !prev || !time) return { missing: { name: !name, prev: !prev, time: !time } };
|
|
const rowRect = row.getBoundingClientRect();
|
|
const timeRect = time.getBoundingClientRect();
|
|
const nameRect = name.getBoundingClientRect();
|
|
return {
|
|
nameWeight: getComputedStyle(name).fontWeight,
|
|
timeRight: rowRect.right - timeRect.right,
|
|
// Timestamp must sit to the right of the name's right edge.
|
|
timeAfterName: timeRect.left >= nameRect.right - 4,
|
|
};
|
|
});
|
|
assert(!data.missing, 'missing sub-elements: ' + JSON.stringify(data.missing || {}));
|
|
const w = parseInt(data.nameWeight, 10) || 0;
|
|
assert(w >= 600 || data.nameWeight === 'bold', 'channel name must be bold, got ' + data.nameWeight);
|
|
assert(data.timeRight <= 20, 'timestamp must be right-aligned, got ' + data.timeRight + 'px from row right');
|
|
assert(data.timeAfterName, 'timestamp must be to the right of the name');
|
|
});
|
|
|
|
await step('rows have NO inline share/remove action chips', async () => {
|
|
const offenders = await page.evaluate(() => {
|
|
const rows = document.querySelectorAll('#chList .ch-row');
|
|
let bad = [];
|
|
for (const r of rows) {
|
|
if (r.querySelector('.ch-row-actions, .ch-share, .ch-remove, .ch-share-btn, .ch-remove-btn, [data-share-channel], [data-remove-channel]')) {
|
|
bad.push(r.getAttribute('data-hash') || '?');
|
|
}
|
|
}
|
|
return bad;
|
|
});
|
|
assert(offenders.length === 0,
|
|
'inline action chips found on ' + offenders.length + ' rows: ' + offenders.slice(0, 3).join(','));
|
|
});
|
|
|
|
await step('header has NO analytics / chart-emoji chip', async () => {
|
|
const hits = await page.evaluate(() => {
|
|
const sidebar = document.querySelector('.ch-sidebar');
|
|
const header = sidebar && sidebar.querySelector('.ch-sidebar-header');
|
|
if (!header) return { noHeader: true };
|
|
const hasLink = !!header.querySelector('.ch-analytics-link, a[href*="analytics"]');
|
|
const hasEmoji = (header.textContent || '').indexOf('\uD83D\uDCCA') !== -1;
|
|
return { hasLink, hasEmoji };
|
|
});
|
|
assert(!hits.noHeader, 'channels sidebar header not found');
|
|
assert(!hits.hasLink, 'analytics link must be removed from header');
|
|
assert(!hits.hasEmoji, '📊 emoji must be removed from header');
|
|
});
|
|
|
|
await step('tap a row → URL hash changes to channel detail route', async () => {
|
|
// Prefer a row whose preview is non-empty (i.e., the channel has at
|
|
// least one observed message), so the downstream detail-view test
|
|
// can rely on .ch-message rendering. Fall back to the first row.
|
|
const targetHash = await page.evaluate(() => {
|
|
const rows = Array.from(document.querySelectorAll('#chList .ch-row[data-hash]'));
|
|
const withPreview = rows.find(r => {
|
|
const p = r.querySelector('.ch-row-preview');
|
|
return p && (p.textContent || '').trim().length > 0
|
|
&& !/^0x/.test((p.textContent || '').trim());
|
|
});
|
|
const r = withPreview || rows[0];
|
|
return r ? r.getAttribute('data-hash') : null;
|
|
});
|
|
assert(targetHash, 'no .ch-row[data-hash] to click');
|
|
await page.click('#chList .ch-row[data-hash="' + targetHash.replace(/"/g, '\\"') + '"]');
|
|
await page.waitForFunction((h) => location.hash.indexOf(encodeURIComponent(h)) !== -1
|
|
|| location.hash.indexOf(h) !== -1, targetHash, { timeout: 5000 });
|
|
const hash = await page.evaluate(() => location.hash);
|
|
assert(hash.indexOf('/channels/') !== -1, 'URL hash should include /channels/<hash>, got ' + hash);
|
|
});
|
|
|
|
// ----- Detail view (mobile, after tap) -----
|
|
await step('detail view header: back affordance + "<name> — <count> messages"', async () => {
|
|
// The header already updates on selection; assert the back chevron and the title format.
|
|
await page.waitForFunction(() => {
|
|
const t = document.querySelector('#chHeader .ch-header-text');
|
|
return t && /—\s*\d+\s*messages/i.test(t.textContent || '');
|
|
}, { timeout: 8000 });
|
|
const data = await page.evaluate(() => {
|
|
const header = document.getElementById('chHeader');
|
|
const back = header && header.querySelector('.ch-back, [data-action="ch-back"], [aria-label*="Back"]');
|
|
const title = header && header.querySelector('.ch-header-text');
|
|
return {
|
|
hasBack: !!back,
|
|
title: title ? (title.textContent || '').trim() : '',
|
|
};
|
|
});
|
|
assert(data.hasBack, 'detail header must include a back button (.ch-back / data-action=ch-back)');
|
|
assert(/—\s*\d+\s*messages/i.test(data.title), 'header title must be "<name> — <count> messages", got: ' + data.title);
|
|
});
|
|
|
|
await step('detail view renders at least one .ch-message (avatar + bubble + footer)', async () => {
|
|
// Wait up to 10s for messages to load. If the chosen channel renders
|
|
// an empty-state, fall back to scanning the entire channel list for
|
|
// the busiest one and re-opening it.
|
|
let ok = await page.evaluate(async () => {
|
|
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
|
|
for (let i = 0; i < 50; i++) {
|
|
const m = document.querySelector('.ch-message');
|
|
if (m) {
|
|
const av = m.querySelector('.ch-avatar');
|
|
const body = m.querySelector('.ch-message-bubble, .ch-msg-bubble');
|
|
const foot = m.querySelector('.ch-message-meta, .ch-msg-meta');
|
|
if (av && body && foot) return true;
|
|
}
|
|
await sleep(200);
|
|
}
|
|
return false;
|
|
});
|
|
if (!ok) {
|
|
// Go back to the list and try the row with the highest visible
|
|
// message count in its preview (e.g. "N messages").
|
|
await page.evaluate(() => {
|
|
const back = document.querySelector('.ch-back, [data-action="ch-back"]');
|
|
if (back) back.click();
|
|
else history.replaceState(null, '', '#/channels');
|
|
});
|
|
await page.waitForSelector('#chList .ch-row[data-hash]', { timeout: 5000 });
|
|
const altHash = await page.evaluate(() => {
|
|
const rows = Array.from(document.querySelectorAll('#chList .ch-row[data-hash]'));
|
|
let best = null, bestN = -1;
|
|
for (const r of rows) {
|
|
const p = r.querySelector('.ch-row-preview');
|
|
const t = (p ? p.textContent || '' : '').trim();
|
|
const m = t.match(/(\d+)\s+messages/i);
|
|
const n = m ? parseInt(m[1], 10) : (t && !/^0x/.test(t) ? 1 : 0);
|
|
if (n > bestN) { bestN = n; best = r.getAttribute('data-hash'); }
|
|
}
|
|
return best;
|
|
});
|
|
if (altHash) {
|
|
await page.click('#chList .ch-row[data-hash="' + altHash.replace(/"/g, '\\"') + '"]');
|
|
ok = await page.evaluate(async () => {
|
|
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
|
|
for (let i = 0; i < 50; i++) {
|
|
const m = document.querySelector('.ch-message');
|
|
if (m) {
|
|
const av = m.querySelector('.ch-avatar');
|
|
const body = m.querySelector('.ch-message-bubble, .ch-msg-bubble');
|
|
const foot = m.querySelector('.ch-message-meta, .ch-msg-meta');
|
|
if (av && body && foot) return true;
|
|
}
|
|
await sleep(200);
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
}
|
|
assert(ok, '.ch-message with avatar+bubble+footer not rendered in detail view');
|
|
});
|
|
|
|
await ctx.close();
|
|
|
|
// ----- Desktop (1024x800) -----
|
|
const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 } });
|
|
const p2 = await ctx2.newPage();
|
|
await p2.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
|
|
await p2.waitForSelector('.ch-layout', { timeout: 10000 });
|
|
await p2.waitForTimeout(200);
|
|
|
|
await step('desktop (1024px): two-pane layout preserved', async () => {
|
|
const dir = await p2.evaluate(() => {
|
|
const l = document.querySelector('.ch-layout');
|
|
return l ? getComputedStyle(l).flexDirection : null;
|
|
});
|
|
assert(dir === 'row', 'desktop ch-layout flex-direction must remain "row", got ' + dir);
|
|
});
|
|
|
|
await browser.close();
|
|
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed' + (failed ? ', ' + failed + ' failed' : ''));
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
run().catch(err => { console.error('Fatal:', err); process.exit(1); });
|