1// @ts-check 2/** 3 * Layout sensor for the portfolio tables. 4 * 5 * The bug this exists to prevent: these tables are CSS grids with fixed-pixel tracks, and a 6 * grid item defaults to `min-width: auto` / `overflow: visible`. A label wider than its track 7 * is painted over the NEXT column instead of being clipped -- which is how a prediction market 8 * row printed its asset name straight on top of the Side pill, two strings sharing one set of 9 * pixels. Nothing in the build could see it; it only existed as geometry in a browser. 10 * 11 * So we measure geometry in a browser. The harness renders the real markup against the real 12 * stylesheet (served from Components/ by static-server.mjs, not a copy) and this spec asserts: 13 * 14 * 1. No two cells in a row may overlap. <- the guarantee, enforced always 15 * 2. No cell may paint outside its own row box. <- the guarantee, enforced always 16 * 3. Realistic content must not be truncated. <- quality check, needs the real webfonts 17 * 18 * Check 3 is what stops someone "fixing" an overlap by clipping every column to nothing. It 19 * depends on font metrics, so it is skipped (loudly) when the Google Fonts CDN is unreachable. 20 * Checks 1 and 2 hold in any font and never skip. 21 */ 22 23import { test, expect } from '@playwright/test'; 24import { TABLES, VIEWPORTS } from './portfolio-table-fixtures.mjs'; 25 26const HARNESS_URL = '/js/portfolio/__tests__/e2e/portfolio-tables.html'; 27 28/** Sub-pixel slack. Browsers land fractional layout values; 0.5px is well under one glyph. */ 29const EPSILON = 0.5; 30 31/** 32 * Reads every row of every fixture table out of the page as plain geometry. 33 * Done in one evaluate call so all rects come from a single layout pass. 34 */ 35async function measure(page) { 36 return page.evaluate(() => { 37 const out = []; 38 for (const table of document.querySelectorAll('[data-table]')) { 39 for (const row of table.querySelectorAll('[data-row]')) { 40 const rowRect = row.getBoundingClientRect(); 41 const style = getComputedStyle(row); 42 // Cells may be padded away from the row's border box; compare against the 43 // content box so padding is not mistaken for an overflow. 44 const contentLeft = rowRect.left + parseFloat(style.paddingLeft || '0'); 45 const contentRight = rowRect.right - parseFloat(style.paddingRight || '0'); 46 const cells = []; 47 for (const cell of row.children) { 48 const r = cell.getBoundingClientRect(); 49 cells.push({ 50 col: cell.getAttribute('data-col') || cell.className.split(' ')[0], 51 text: (cell.textContent || '').trim(), 52 left: r.left, right: r.right, top: r.top, bottom: r.bottom, 53 width: r.width, 54 scrollWidth: cell.scrollWidth, 55 clientWidth: cell.clientWidth, 56 // The card layouts hide columns outright below 600px. A `display: none` 57 // cell has a zero-area rect at the origin -- it paints nothing, so it can 58 // neither overlap nor escape, and judging it produces pure noise. 59 painted: r.width > 0 && r.height > 0, 60 // A cell hosting a filter dropdown always reports more scroll width 61 // than it has: the open menu is wider than the column by design. 62 // Truncation is meaningless there, so it is measured separately. 63 hasFilter: !!cell.querySelector('.cfd-wrapper'), 64 // Content the fixture declares as designed-to-be-cut (a prediction 65 // market's question). Exempt from the truncation check only -- it is 66 // still held to containment and overlap like every other cell. 67 truncatable: cell.getAttribute('data-truncatable') === '1', 68 }); 69 } 70 out.push({ 71 table: table.getAttribute('data-table'), 72 row: row.getAttribute('data-row'), 73 severity: row.getAttribute('data-severity'), 74 rowLeft: contentLeft, 75 rowRight: contentRight, 76 cells, 77 }); 78 } 79 } 80 return out; 81 }); 82} 83 84/** Two rectangles overlap only if they intersect on BOTH axes -- a wrapped card grid is fine. */ 85function overlaps(a, b) { 86 const horizontal = a.left < b.right - EPSILON && b.left < a.right - EPSILON; 87 const vertical = a.top < b.bottom - EPSILON && b.top < a.bottom - EPSILON; 88 return horizontal && vertical; 89} 90 91function describeRow(row) { 92 const table = TABLES.find(t => t.id === row.table); 93 return `${table ? table.label : row.table} row=${row.row} (${row.severity})`; 94} 95 96for (const viewport of VIEWPORTS) { 97 test.describe(`portfolio tables @ ${viewport.name} (${viewport.width}px)`, () => { 98 test.beforeEach(async ({ page }) => { 99 await page.setViewportSize({ width: viewport.width, height: viewport.height }); 100 await page.goto(HARNESS_URL); 101 await page.waitForFunction(() => window.__harnessReady === true); 102 }); 103 104 test('no two cells in a row overlap', async ({ page }) => { 105 const rows = await measure(page); 106 expect(rows.length, 'harness rendered no rows').toBeGreaterThan(0); 107 108 const collisions = []; 109 for (const row of rows) { 110 const visible = row.cells.filter(c => c.painted); 111 for (let i = 0; i < visible.length; i++) { 112 for (let j = i + 1; j < visible.length; j++) { 113 if (overlaps(visible[i], visible[j])) { 114 collisions.push( 115 `${describeRow(row)}: "${visible[i].col}" (${visible[i].text}) ` 116 + `overlaps "${visible[j].col}" (${visible[j].text}) -- ` 117 + `[${visible[i].left.toFixed(1)}..${visible[i].right.toFixed(1)}] vs ` 118 + `[${visible[j].left.toFixed(1)}..${visible[j].right.toFixed(1)}]`); 119 } 120 } 121 } 122 } 123 expect(collisions, collisions.join('\n')).toEqual([]); 124 }); 125 126 test('no cell paints outside its row', async ({ page }) => { 127 const rows = await measure(page); 128 const escapes = []; 129 for (const row of rows) { 130 for (const cell of row.cells) { 131 if (!cell.painted) continue; 132 if (cell.left < row.rowLeft - EPSILON || cell.right > row.rowRight + EPSILON) { 133 escapes.push( 134 `${describeRow(row)}: "${cell.col}" (${cell.text}) spans ` 135 + `[${cell.left.toFixed(1)}..${cell.right.toFixed(1)}] outside row content box ` 136 + `[${row.rowLeft.toFixed(1)}..${row.rowRight.toFixed(1)}]`); 137 } 138 } 139 } 140 expect(escapes, escapes.join('\n')).toEqual([]); 141 }); 142 143 test('realistic content is not truncated', async ({ page }) => { 144 const fontsLoaded = await page.evaluate(() => window.__harnessFontsLoaded === true); 145 test.skip(!fontsLoaded, 146 'Webfonts (Space Grotesk / Source Code Pro) did not load -- fallback metrics ' 147 + 'would make truncation measurements meaningless. Overlap checks still ran.'); 148 149 const rows = await measure(page); 150 const truncated = []; 151 // Header labels are static text the developer chose, so a column too narrow to 152 // show its own heading is as much a defect as a clipped value. Rows marked 153 // adversarial are exempt -- being ellipsized is the point of those. 154 const judged = rows.filter(r => r.severity === 'ordinary' || r.severity === 'header'); 155 for (const row of judged) { 156 for (const cell of row.cells) { 157 if (!cell.painted || cell.hasFilter || cell.truncatable) continue; 158 if (cell.scrollWidth > cell.clientWidth + EPSILON) { 159 truncated.push( 160 `${describeRow(row)}: "${cell.col}" (${cell.text}) needs ` 161 + `${cell.scrollWidth}px but its column is only ${cell.clientWidth}px. ` 162 + `Widen the track -- do not solve an overlap by clipping everything.`); 163 } 164 } 165 } 166 expect(truncated, truncated.join('\n')).toEqual([]); 167 }); 168 169 test('an outcome question reads in full with its metadata aligned under it', async ({ page }) => { 170 // The two defects this pins, both invisible to the checks above. 171 // 172 // The question is `truncatable`, so the truncation check
172skips it -- and it now wraps, 173 // which makes a cut question a VERTICAL overflow that a scrollWidth comparison cannot 174 // see at all. The containment check cannot see it either: the cell clips with 175 // `overflow: hidden`, so nothing ever paints outside the row. 176 // 177 // The alignment half is the other half of the same row. The shared cell rule 178 // (`.outcomes-table .table-row>span`) sets `align-items: center`, and it outranks a 179 // plain `.outcomes-table .col-asset` on specificity -- which centred the column stack 180 // and left the side chip and countdown starting under the middle of the question 181 // instead of at its first word. Geometry is the only place that shows up. 182 const cells = await page.evaluate(() => { 183 const out = []; 184 for (const row of document.querySelectorAll('[data-table="outcomes"] [data-row]')) { 185 const q = row.querySelector('.outcome-question'); 186 const meta = row.querySelector('.outcome-meta'); 187 if (!q || !meta) continue; 188 out.push({ 189 row: row.getAttribute('data-row'), 190 questionLeft: q.getBoundingClientRect().left, 191 metaLeft: meta.getBoundingClientRect().left, 192 // A clamped question overflows its own box vertically. 193 scrollHeight: q.scrollHeight, 194 clientHeight: q.clientHeight, 195 lineHeight: parseFloat(getComputedStyle(q).lineHeight), 196 }); 197 } 198 return out; 199 }); 200 expect(cells.length, 'no outcome rows carried a question and a metadata line').toBeGreaterThan(0); 201 202 const misaligned = cells.filter(c => Math.abs(c.questionLeft - c.metaLeft) > EPSILON); 203 expect(misaligned, misaligned.map(c => 204 `outcomes row=${c.row}: the metadata line starts at x=${c.metaLeft.toFixed(1)} but the ` 205 + `question above it starts at x=${c.questionLeft.toFixed(1)}. They share a column stack ` 206 + `and must share a left edge.`).join('\n')).toEqual([]); 207 208 // Font-dependent: how many lines the fixture's 108-character question needs is a 209 // metrics question, so this half skips with the same reasoning as the check above. 210 const fontsLoaded = await page.evaluate(() => window.__harnessFontsLoaded === true); 211 test.skip(!fontsLoaded, 212 'Webfonts did not load -- fallback metrics would make the line count meaningless. ' 213 + 'The alignment assertion above still ran.'); 214 215 const clamped = cells.filter(c => c.scrollHeight > c.clientHeight + EPSILON); 216 expect(clamped, clamped.map(c => 217 `outcomes row=${c.row}: the question needs ${c.scrollHeight}px but its cell shows only ` 218 + `${c.clientHeight}px (~${Math.round(c.clientHeight / c.lineHeight)} lines), so a real ` 219 + `market title is being cut. Give the clamp another line or the column more width.`) 220 .join('\n')).toEqual([]); 221 }); 222 223 test('an open column filter menu is not clipped by its header', async ({ page }) => { 224 // The containment rules stop at header cells that host a filter dropdown, because 225 // the open menu is an absolutely-positioned popover INSIDE the cell. That exemption 226 // is easy to lose -- someone tightening the overflow rules would silently amputate 227 // every filter menu, and nothing else in the suite would notice. So: walk from each 228 // open popover up to its table and assert nothing on the way clips. 229 const clipped = await page.evaluate(() => { 230 const bad = []; 231 for (const popover of document.querySelectorAll('.cfd-popover')) { 232 // Below the card cutover the whole table header is display:none, so there is 233 // no menu on screen to clip. offsetParent is null for a subtree that is not 234 // rendered at all -- distinct from a menu that rendered and then got clipped. 235 if (popover.offsetParent === null) continue; 236 if (popover.getBoundingClientRect().height === 0) { 237 bad.push(`"${popover.getAttribute('data-popover')}" menu rendered with no height`); 238 continue; 239 } 240 let node = popover.parentElement; 241 while (node && !node.hasAttribute('data-table')) { 242 const overflow = getComputedStyle(node).overflow; 243 if (overflow !== 'visible') { 244 bad.push(`"${popover.getAttribute('data-popover')}" menu is clipped by ` 245 + `<${node.tagName.toLowerCase()} class="${node.className}"> ` 246 + `(overflow: ${overflow})`); 247 } 248 node = node.parentElement; 249 } 250 } 251 return bad; 252 }); 253 expect(clipped.length, clipped.join('\n')).toBe(0); 254 }); 255 }); 256}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.