|
1 | 1 | import { test } from 'node:test' |
2 | 2 | import assert from 'node:assert/strict' |
3 | 3 | import { readFileSync } from 'node:fs' |
| 4 | +import { createRequire } from 'node:module' |
| 5 | +import { join } from 'node:path' |
4 | 6 | import { fileURLToPath } from 'node:url' |
5 | 7 | import { runInNewContext } from 'node:vm' |
6 | 8 | import { build } from 'esbuild' |
@@ -176,3 +178,86 @@ test('#1642: reasoning that is only markup gets a neutral summary, never a bare |
176 | 178 | assert.ok(peek, 'the folded row never reads as an empty frame') |
177 | 179 | assert.equal(text(peek), '2 lines of reasoning') |
178 | 180 | }) |
| 181 | + |
| 182 | +// #1718: the folded row must stay inside the column it sits in. jsdom lays no boxes out (every |
| 183 | +// element is 0×0), so the unit suite cannot read scrollWidth/clientWidth here; what it can read is |
| 184 | +// the cascade the app ships, mounted around this row. The pixel evidence, taken from the same |
| 185 | +// render in headless Chromium at 900 px and 400 px, is in the pull request: before the fix the |
| 186 | +// summary was 1308 px wide inside a 560 px / 312 px box. |
| 187 | +const { JSDOM } = createRequire(join(process.env.DSH_HARNESS, 'package.json'))('jsdom') |
| 188 | +const mountRow = (row, width) => { |
| 189 | + const { window } = new JSDOM(`<!doctype html><html><head><style>${CONVERSATION_CSS}</style></head><body><div class="column" style="width:${width}px"></div></body></html>`) |
| 190 | + const doc = window.document |
| 191 | + const build = (node) => { |
| 192 | + if (node === null || node === undefined || typeof node === 'boolean') return null |
| 193 | + if (Array.isArray(node)) return node.map(build) |
| 194 | + if (typeof node !== 'object') return doc.createTextNode(String(node)) |
| 195 | + if (node.type === Fragment) return build(node.props?.children) |
| 196 | + const element = doc.createElement(node.type) |
| 197 | + for (const [name, value] of Object.entries(node.props ?? {})) { |
| 198 | + if (name === 'children' || name === 'key') continue |
| 199 | + if (typeof value === 'string' || typeof value === 'number') element.setAttribute(name === 'className' ? 'class' : name, value) |
| 200 | + } |
| 201 | + for (const child of [build(node.props?.children ?? [])].flat(Infinity)) if (child) element.append(child) |
| 202 | + return element |
| 203 | + } |
| 204 | + const rowEl = build(row) |
| 205 | + doc.querySelector('.column').append(rowEl) |
| 206 | + return { window, doc, style: (selector, property) => window.getComputedStyle(doc.querySelector(selector))[property] } |
| 207 | +} |
| 208 | +const LONG = 'x'.repeat(2000) |
| 209 | +const longRow = () => { |
| 210 | + const blocks = [think(LONG)] |
| 211 | + return thinkingRow(render(step(1, blocks), [step(1, blocks)])) |
| 212 | +} |
| 213 | + |
| 214 | +test('#1718: a 2000-character line cannot widen the folded row past its box (900 px and 400 px)', () => { |
| 215 | + const row = longRow() |
| 216 | + assert.ok(row, 'the row renders for the long reasoning') |
| 217 | + for (const width of [900, 400]) { |
| 218 | + const { doc, style } = mountRow(row, width) |
| 219 | + // The ceiling is still the details box, not the whole column. |
| 220 | + assert.equal(style('.bt-think', 'maxWidth'), 'min(560px,78%)') |
| 221 | + // An inline-flex box sizes to its content unless it is bounded; the bound is its own box. |
| 222 | + assert.equal(style('.bt-think summary', 'maxWidth'), '100%', `summary is bounded by its box at ${width}px`) |
| 223 | + assert.equal(style('.bt-think summary', 'minWidth'), '0px') |
| 224 | + // One line: the label inside the summary must not wrap when the preview is squeezed. |
| 225 | + assert.equal(style('.bt-think summary', 'whiteSpace'), 'nowrap', `the row stays one line at ${width}px`) |
| 226 | + // The preview fills what is left and clips with an ellipsis, so the line cannot run wide. |
| 227 | + assert.equal(style('.bt-think-peek', 'maxWidth'), '100%') |
| 228 | + assert.equal(style('.bt-think-peek', 'minWidth'), '0px') |
| 229 | + assert.equal(style('.bt-think-peek', 'overflow'), 'hidden') |
| 230 | + assert.equal(style('.bt-think-peek', 'textOverflow'), 'ellipsis') |
| 231 | + assert.equal(style('.bt-think-peek', 'whiteSpace'), 'nowrap') |
| 232 | + // Opened, the same unbroken token wraps instead of running past the column. |
| 233 | + assert.equal(style('.bt-think-text', 'overflowWrap'), 'anywhere') |
| 234 | + assert.equal(style('.bt-think-text', 'whiteSpace'), 'pre-wrap') |
| 235 | + // Folded, with the caret and the label it always showed. |
| 236 | + const details = doc.querySelector('.bt-think') |
| 237 | + assert.equal(details.open, false) |
| 238 | + assert.equal(doc.querySelector('.bt-think-caret').textContent, '›') |
| 239 | + assert.match(doc.querySelector('summary').textContent, /Thought it through/) |
| 240 | + } |
| 241 | +}) |
| 242 | + |
| 243 | +test('#1718: the fold stays the design — one line, no block summary, no hidden caret', () => { |
| 244 | + const rules = [...CONVERSATION_CSS.matchAll(/\.bt-think[^{]*\{[^}]*\}/g)].map(match => match[0]) |
| 245 | + const summary = rules.find(rule => /^\.bt-think summary\{/.test(rule)) |
| 246 | + assert.ok(summary, 'the row has its summary rule') |
| 247 | + assert.match(summary, /display:inline-flex/, 'the summary stays a one-line inline flex row') |
| 248 | + assert.match(summary, /white-space:nowrap/, 'the label cannot wrap into a stacked block') |
| 249 | + assert.doesNotMatch(summary, /display:(?:block|flex)/, 'the fold is not turned into a stacked block') |
| 250 | + assert.doesNotMatch(summary, /overflow:hidden/, 'nothing clips the caret away') |
| 251 | + // Opened, the body carries the whole 2000-character line and the preview steps aside (#1636). |
| 252 | + const { doc } = mountRow(longRow(), 900) |
| 253 | + doc.querySelector('details').open = true |
| 254 | + assert.equal(doc.querySelector('.bt-think-text').textContent, LONG) |
| 255 | + assert.equal(doc.querySelector('.bt-think-caret').textContent, '›') |
| 256 | +}) |
| 257 | + |
| 258 | +test('#1718: the bundle the app loads carries the same containment rules', () => { |
| 259 | + const bundle = readFileSync(new URL('../client.js', import.meta.url), 'utf8') |
| 260 | + assert.ok(bundle.includes('.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px;max-width:100%;min-width:0;white-space:nowrap}'), 'summary bounded in client.js') |
| 261 | + assert.ok(bundle.includes('.bt-think-peek{min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'), 'peek bounded in client.js') |
| 262 | + assert.ok(bundle.includes('.bt-think-text{white-space:pre-wrap;overflow-wrap:anywhere;'), 'body wraps in client.js') |
| 263 | +}) |
0 commit comments