test(#1487): failing E2E for BYOP modal layout — header too tall, body occluded

Captures the bug reported by @EldoonNemar: on mobile the .byop-header
swells to ~73px because position:sticky + negative margin assumes
desktop padding (24px) while .modal switches to 16px padding on mobile,
and the close-button box inflates the header further. The description
paragraph then begins inside the sticky-header band and is occluded.

Asserts on mobile (390x844) AND desktop (1280x800):
  - .byop-header height <= 56px
  - .text-muted description top >= .byop-header bottom (no occlusion)
  - textarea / Decode button do not overflow the modal client rect

Wired into deploy.yml e2e-test job. Expected red: header is 73px today
and description starts inside the sticky header.
This commit is contained in:
corescope-bot
2026-05-29 14:18:34 +00:00
parent 022f3d8f0d
commit bb1a9f4844
2 changed files with 128 additions and 0 deletions
+1
View File
@@ -341,6 +341,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-add-modal-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-share-color-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-ws-batch-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1487-byop-modal-layout-e2e.js 2>&1 | tee -a e2e-output.txt
- name: Collect frontend coverage (parallel)
if: success() && github.event_name == 'push'
+127
View File
@@ -0,0 +1,127 @@
/**
* E2E (#1487): BYOP modal must render with a usable layout — the title bar
* must NOT consume most of the dialog height and the body controls
* (description, textarea, Decode button) must be visible and not occluded
* by the sticky header.
*
* Reporter: @EldoonNemar — "The dialog text can't be seen due to the title
* bar being massive."
*
* Repro:
* 1. Open /#/packets on mobile (390x844).
* 2. Click the 📦 BYOP toolbar button.
* 3. Observe the modal: the .byop-header swells (~73px tall) and the
* next-sibling description paragraph (`.text-muted`) starts INSIDE
* the sticky-header band, getting visually clipped/occluded.
*
* Root cause: `.byop-header` uses `position: sticky` + a negative
* `margin: -24px -24px 12px` that assumes desktop `.modal` padding of
* 24px — but `.modal` switches to 16px padding on mobile. The close
* button's box (border + padding) further inflates the header. The
* description paragraph then begins at top≈85 inside a header that
* spans 2497, hiding the text.
*
* Fix expectation:
* - Header height is bounded (<= 56px is a reasonable target).
* - The description paragraph's top edge is BELOW the sticky-header
* bottom edge — i.e. no visual occlusion.
* - The textarea and Decode button are fully within the modal client rect.
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1487-byop-modal-layout-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(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
async function probeModal(page) {
return page.evaluate(() => {
const m = document.querySelector('.byop-modal');
if (!m) return { err: 'no modal' };
const hdr = m.querySelector('.byop-header');
const desc = m.querySelector('.text-muted');
const ta = m.querySelector('.byop-input');
const btn = m.querySelector('#byopDecode');
const mr = m.getBoundingClientRect();
const hr = hdr.getBoundingClientRect();
const dr = desc.getBoundingClientRect();
const tr = ta.getBoundingClientRect();
const br = btn.getBoundingClientRect();
return {
modalH: Math.round(mr.height),
hdrH: Math.round(hr.height),
hdrBottom: Math.round(hr.bottom),
descTop: Math.round(dr.top),
taBottom: Math.round(tr.bottom),
btnBottom: Math.round(br.bottom),
modalBottom: Math.round(mr.bottom),
};
});
}
async function openBYOP(page) {
await page.waitForSelector('[data-action="pkt-byop"]', { timeout: 8000 });
await page.evaluate(() => {
document.querySelectorAll('.byop-overlay').forEach(o => o.remove());
document.querySelector('[data-action="pkt-byop"]').click();
});
await page.waitForSelector('.byop-modal', { timeout: 5000 });
await page.waitForTimeout(200);
}
async function runViewport(browser, label, viewport) {
const ctx = await browser.newContext({ viewport });
const page = await ctx.newPage();
page.setDefaultTimeout(10000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n--- viewport ${label} (${viewport.width}x${viewport.height}) ---`);
await step('navigate to /packets and open BYOP', async () => {
await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
await openBYOP(page);
});
await step('header height is bounded (<= 56px)', async () => {
const p = await probeModal(page);
assert(p.hdrH <= 56, `header height ${p.hdrH}px > 56px cap (modal=${p.modalH}px)`);
});
await step('description paragraph is NOT occluded by sticky header', async () => {
const p = await probeModal(page);
assert(p.descTop >= p.hdrBottom,
`description top (${p.descTop}) starts INSIDE sticky header band (header bottom=${p.hdrBottom})`);
});
await step('textarea and Decode button do not overflow modal client rect', async () => {
const p = await probeModal(page);
assert(p.taBottom <= p.modalBottom + 1, `textarea bottom (${p.taBottom}) overflows modal (${p.modalBottom})`);
assert(p.btnBottom <= p.modalBottom + 1, `Decode button bottom (${p.btnBottom}) overflows modal`);
});
await ctx.close();
}
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
console.log(`\n=== #1487 BYOP modal layout E2E against ${BASE} ===`);
await runViewport(browser, 'mobile', { width: 390, height: 844 });
await runViewport(browser, 'desktop', { width: 1280, height: 800 });
await browser.close();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
})().catch(e => { console.error(e); process.exit(1); });