PageSourceSearch

https://degenai.dev/js/portfolio/__tests__/e2e/portfolio-table-overflow.spec.678y5uu5cw.js

js degenai.dev collected 2026-10-06 14:18:52 UTC 14,477 bytes, 256 lines download raw bytes

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.