mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-04 20:31:22 +00:00
test(#1367): RED — channels chat-app row layout + detail view E2E
New Playwright E2E (375x800) asserts the locked design from #1367: - .ch-row 80px tall, full-width, with .ch-avatar (hash-derived bg + 2-3 char text), bold name, ellipsized preview, right-aligned relative timestamp - NO inline .ch-row-actions / .ch-share / .ch-remove on rows - NO analytics chip (📊) in sidebar header - Tapping a row changes the URL hash to /channels/<hash> - Detail view: back chevron + '<name> — <N> messages' title; at least one .ch-message renders with avatar+bubble+footer. Desktop (1024x800) two-pane layout still preserved. Wired into deploy.yml e2e step. This commit must fail CI on assertions before the production change lands (red commit gate).
This commit is contained in:
@@ -286,6 +286,7 @@ jobs:
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-vcr-overlap-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1367-channels-chat-app-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1329-map-controls-accordion-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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 () => {
|
||||
const targetHash = await page.evaluate(() => {
|
||||
const r = document.querySelector('#chList .ch-row[data-hash]');
|
||||
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 8s for messages to load (some channels may be empty — pick the busiest).
|
||||
const ok = await page.evaluate(async () => {
|
||||
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
|
||||
for (let i = 0; i < 40; 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); });
|
||||
Reference in New Issue
Block a user