diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8c5cb..3289a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **Result-row cap** with a 100 / 500 / 1000 / 5000 / 10000 selector in the result + toolbar (default **500**, a global preference persisted across tabs and reloads). + A normal `SELECT` now fetches at most the selected cap rather than pulling every + row over the wire: ClickHouse stops cleanly at the cap server-side + (`max_result_rows` + `result_overflow_mode = 'break'`), a small client-side guard + trims the block-boundary overage `break` can leave, and a **"first N (capped)"** + badge appears in the stats row when the limit is hit. Changing the selector + re-runs the current query, so raising the cap genuinely fetches more. The display + grid now renders up to the selected cap (10000 actually shows 10000). EXPLAIN / + PIPELINE / ESTIMATE runs are exempt. (#86) - Playwright e2e now runs on **WebKit** in addition to Chromium and Firefox, so many Safari regressions on the `html{zoom}`-based layout fail CI instead of shipping silently. README gained a **Supported browsers** stance: desktop diff --git a/src/core/stream.js b/src/core/stream.js index 92d8664..fcce32d 100644 --- a/src/core/stream.js +++ b/src/core/stream.js @@ -7,8 +7,14 @@ // `applyStreamLine` folds one parsed object into a mutable result; keeping it // pure (no fetch, no DOM) makes the streaming parser fully unit-testable. -/** A fresh, empty result object for a query run in output format `fmt`. */ -export function newResult(fmt) { +/** + * A fresh, empty result object for a query run in output format `fmt`. `rowLimit` + * (default 0 = uncapped) is the client-side row cap: the server's + * result_overflow_mode='break' stops at the cap but can overshoot to the next + * block boundary, so applyStreamLine trims any rows past `rowLimit` and flags + * `capped` once it's reached. + */ +export function newResult(fmt, rowLimit = 0) { return { columns: [], rows: [], @@ -18,6 +24,8 @@ export function newResult(fmt) { error: null, cancelled: false, pct: 0, + rowLimit, + capped: false, }; } @@ -26,7 +34,12 @@ export function applyStreamLine(json, result) { if (json.meta) { result.columns = json.meta.map((m) => ({ name: m.name, type: m.type })); } else if (json.row) { - result.rows.push(result.columns.map((c) => json.row[c.name])); + // At the cap: drop the row (block-boundary overage from `break`) and flag it. + if (result.rowLimit > 0 && result.rows.length >= result.rowLimit) { + result.capped = true; + } else { + result.rows.push(result.columns.map((c) => json.row[c.name])); + } } else if (json.progress) { const p = json.progress; const total = +p.total_rows_to_read || 0; diff --git a/src/net/ch-client.js b/src/net/ch-client.js index 3ec2ba7..794198d 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -370,7 +370,7 @@ export async function loadEntityDoc(ctx, name, sqlString) { * * @param ctx * @param sql - * @param o { format, signal, onLine(json), onChunk(), onRaw(text) } + * @param o { format, signal, resultRowLimit, onLine(json), onChunk(), onRaw(text) } */ export async function runQuery(ctx, sql, o = {}) { const fmt = o.format || 'Table'; @@ -383,6 +383,16 @@ export async function runQuery(ctx, sql, o = {}) { : fmt === 'TSV' ? 'TabSeparatedWithNamesAndTypes' : fmt; + // Cap a normal result query server-side: max_result_rows stops the read at N + // and result_overflow_mode='break' makes ClickHouse stop cleanly at a block + // boundary (no error, no further data pulled) rather than throwing. The caller + // decides scope — it passes resultRowLimit for normal SELECTs (Table + explicit + // FORMAT) and 0 for EXPLAIN/PIPELINE/ESTIMATE (which also run as 'Table', so the + // exemption can't be told apart by format here). `break` can overshoot by up to + // a block on the streaming path, which the applyStreamLine guard trims. + const cap = o.resultRowLimit > 0 + ? { max_result_rows: o.resultRowLimit, result_overflow_mode: 'break' } + : {}; const url = chUrl(ctx.origin, { format: fmtParam, // wait_end_of_query buffers the whole response server-side so the HTTP @@ -390,7 +400,7 @@ export async function runQuery(ctx, sql, o = {}) { // wait for the query to finish: ~16s vs ~0.5s on a 1.3M-row scan). Keep it // only for raw modes (read whole anyway); the streaming Table path drops it // and surfaces mid-stream errors via the in-band `exception` line instead. - extra: { ...(isStreaming ? {} : { wait_end_of_query: 1 }), add_http_cors_header: 1 }, + extra: { ...(isStreaming ? {} : { wait_end_of_query: 1 }), ...cap, add_http_cors_header: 1 }, // Tagging the request with a query_id lets Cancel issue KILL QUERY for it. params: o.queryId ? { query_id: o.queryId } : {}, }); diff --git a/src/state.js b/src/state.js index 0d9a99c..8c8214e 100644 --- a/src/state.js +++ b/src/state.js @@ -25,8 +25,20 @@ export const KEYS = { saved: 'asb:saved', history: 'asb:history', libraryName: 'asb:libraryName', + resultRowLimit: 'asb:resultRowLimit', }; +/** Row-limit options for the result cap selector (shared between state + UI). */ +export const RESULT_ROW_LIMIT_OPTIONS = [100, 500, 1000, 5000, 10000]; + +/** Default row cap when none is persisted (or a stored value is unrecognized). */ +export const DEFAULT_RESULT_ROW_LIMIT = 500; + +/** Snap a row-limit to a known option, falling back to the default. Pure. */ +export function normalizeRowLimit(n) { + return RESULT_ROW_LIMIT_OPTIONS.includes(n) ? n : DEFAULT_RESULT_ROW_LIMIT; +} + /** Default name for a fresh / unnamed saved-query library. */ export const DEFAULT_LIBRARY_NAME = 'SQL Library'; @@ -46,6 +58,11 @@ export function createState(read = { loadJSON, loadStr }) { nextTabId: 2, theme: read.loadStr(KEYS.theme, 'light'), density: 'comfortable', + // Global cap on how many rows a normal SELECT fetches (server-side + // max_result_rows + a client-side guard; see runQuery / applyStreamLine). + // One persisted preference, default 500; a non-option stored value snaps + // back to the default so the selector always reflects a real choice. + resultRowLimit: normalizeRowLimit(parseInt(read.loadStr(KEYS.resultRowLimit, '500'), 10)), sidebarPx: clamp(parseInt(read.loadStr(KEYS.sidebarPx, '248'), 10), 180, 420), editorPct: num(KEYS.editorPct, 45, 15, 85), sideSplitPct: num(KEYS.sideSplitPct, 58, 25, 85), diff --git a/src/styles.css b/src/styles.css index 17f1cc4..e2da104 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1470,6 +1470,26 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } padding: 2px 7px; border-radius: 4px; background: color-mix(in oklab, #ef4444 12%, transparent); } +.capped-badge { + font-family: var(--mono); font-size: 10.5px; color: var(--accent); + padding: 2px 7px; border-radius: 4px; + background: color-mix(in oklab, var(--accent) 12%, transparent); + white-space: nowrap; +} + +/* Result row-cap selector (result toolbar, after the view tabs). */ +.row-limit { display: flex; align-items: center; gap: 6px; } +.row-limit-label { + font-size: 10.5px; color: var(--fg-faint); + text-transform: uppercase; letter-spacing: .05em; +} +.row-limit-select { + height: 24px; + background: var(--bg-input); border: 1px solid var(--border); + border-radius: 5px; color: var(--fg); + font-family: inherit; font-size: 11.5px; + padding: 0 6px; outline: none; cursor: pointer; +} /* scrollbars */ *::-webkit-scrollbar { width: 10px; height: 10px; } diff --git a/src/ui/app.js b/src/ui/app.js index d9ceca5..93fa53d 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -7,7 +7,7 @@ import { h, zoomScale, fixedAnchor } from './dom.js'; import { Icon } from './icons.js'; import { - createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, tabChart, + createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, tabChart, normalizeRowLimit, } from '../state.js'; import { saveJSON, saveStr } from '../core/storage.js'; import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; @@ -471,8 +471,13 @@ export function createApp(env = {}) { fmt = explicitFmt || 'Table'; } + // Cap a normal result query (Table or explicit-FORMAT SELECT) at the global + // row limit; EXPLAIN/PIPELINE/ESTIMATE are exempt (small output, and a cap + // would truncate a plan oddly). The streaming guard reads it off the result; + // runQuery adds the server-side max_result_rows for the Table path. + const rowLimit = explainMode ? 0 : app.state.resultRowLimit; const t0 = now(); - tab.result = newResult(fmt); + tab.result = newResult(fmt, rowLimit); if (explainView) tab.result.explainView = explainView; app.state.resultSort = { col: null, dir: 'asc' }; app.state.runT0 = t0; @@ -494,6 +499,7 @@ export function createApp(env = {}) { try { const out = await ch.runQuery(chCtx, runSql, { format: fmt, + resultRowLimit: rowLimit, queryId: app.state.runQueryId, signal: app.state.abortController.signal, onLine: (json) => applyStreamLine(json, tab.result), @@ -652,6 +658,15 @@ export function createApp(env = {}) { function explainQuery() { return run({ explain: true }); } // Switch the active EXPLAIN view (re-runs the derived query, keeps the mode). function setExplainView(id) { return run({ explainView: id }); } + // Change the global result-row cap: persist the (normalized) preference and + // re-run the current query so a raise genuinely fetches more (server-side cap), + // a lower one stops sooner. run() no-ops on an empty editor, so changing the + // limit with nothing typed just saves the preference. + function setResultRowLimit(n) { + app.state.resultRowLimit = normalizeRowLimit(n); + app.savePref('resultRowLimit', app.state.resultRowLimit); + return run(); + } // Fetch the DDL for `target` (e.g. 'db.table' or 'DATABASE db') with // SHOW CREATE, pretty-print it through formatQuery(), and drop it into the @@ -863,6 +878,7 @@ export function createApp(env = {}) { formatQuery, explainQuery, setExplainView, + setResultRowLimit, showSchemaGraph, expandSchemaGraph, openNodeDetail, diff --git a/src/ui/results.js b/src/ui/results.js index 3d41a28..9115ba4 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -9,6 +9,7 @@ import { looksLikeHtml, prettyValue } from '../core/cell.js'; import { sortRows } from '../core/sort.js'; import { autoChart, schemaKey, chartFieldOptions, chartColors, chartJsConfig, chartCfgValid, normalizeChartCfg, unzoomChartEvent, CHART_ROW_CAP } from '../core/chart-data.js'; import { EXPLAIN_VIEWS } from '../core/explain.js'; +import { RESULT_ROW_LIMIT_OPTIONS } from '../state.js'; import { renderExplainGraph, openPipelineFullscreen, renderSchemaGraph } from './explain-graph.js'; // View id → tab glyph for the EXPLAIN view strip (kept here so core/explain.js @@ -18,9 +19,19 @@ const EXPLAIN_ICONS = { pipeline: Icon.share, estimate: Icon.rows, }; -const VIS_CAP = 5000; +const VIS_CAP = 5000; // fallback display cap for results that carry no row limit (raw / EXPLAIN) const MIN_COL = 48; // px floor for a resized column +/** + * How many rows to render: follow the result's own row cap when set (so a 10000 + * limit renders 10000), else the fixed fallback. The server cap already trims a + * normal SELECT to its limit, so this just keeps the renderers from re-capping + * a large-but-allowed result. Pure — exported for tests. + */ +export function visCap(r) { + return r.rowLimit > 0 ? r.rowLimit : VIS_CAP; +} + /** * New width (px) for a column dragged by `dx` client px. `scale` converts client * px → CSS px under the page `zoom` (computed per element); 0/NaN falls back to @@ -149,6 +160,27 @@ function streamStrip(r) { : h('i', { class: 'sweep' })); } +/** + * A resolves the selection correctly). + sel.value = String(app.state.resultRowLimit); + return h('label', { class: 'row-limit' }, h('span', { class: 'row-limit-label' }, 'Rows'), sel); +} + function buildToolbar(app, r) { const toolbar = h('div', { class: 'res-toolbar' }); if (r && r.schemaGraph) { @@ -196,6 +228,9 @@ function buildToolbar(app, r) { } } toolbar.appendChild(tabs); + // Row-cap selector after the view tabs, for normal result queries only — + // EXPLAIN views are exempt (small output a cap would truncate oddly). + if (!(r && r.explainView)) toolbar.appendChild(rowLimitSelect(app)); toolbar.appendChild(h('div', { style: { flex: '1' } })); // EXPLAIN views suppress the ms/rows/bytes stats — they're not meaningful for a // plan and the freed space lets the five tabs breathe. @@ -226,6 +261,13 @@ function buildToolbar(app, r) { h('span', { class: 'v' }, (r.rawText != null ? '—' : r.rows.length) + ' rows'))); toolbar.appendChild(h('div', { class: 'stat', title: r.progress.rows + ' rows scanned' }, h('span', { class: 'ic' }, Icon.bytes()), h('span', { class: 'v' }, formatBytes(r.progress.bytes)))); + // The result hit the row cap: say so (the fetch stopped at the limit, more + // rows exist). Only the streaming path sets `capped`; raw output can't. + if (r.capped) { + toolbar.appendChild(h('span', { + class: 'capped-badge', title: 'Fetch stopped at the row limit — raise it to see more', + }, 'first ' + r.rowLimit + ' (capped)')); + } } if (r.explainView === 'pipeline' && r.rawText && !r.error) { toolbar.appendChild(h('button', { @@ -248,7 +290,7 @@ function buildToolbar(app, r) { } export function renderJson(r) { - const arr = r.rows.slice(0, VIS_CAP).map((row) => { + const arr = r.rows.slice(0, visCap(r)).map((row) => { const o = {}; r.columns.forEach((c, i) => { o[c.name] = row[i]; }); return o; @@ -294,7 +336,8 @@ export function renderTable(app, r) { if (Object.keys(r.colWidths).length) applyFixedWidths(table, r); const tbody = document.createElement('tbody'); - rows.slice(0, VIS_CAP).forEach((row, ri) => { + const cap = visCap(r); + rows.slice(0, cap).forEach((row, ri) => { const tr = document.createElement('tr'); tr.appendChild(h('td', { class: 'idx' }, String(ri + 1))); row.forEach((v, ci) => { @@ -312,10 +355,10 @@ export function renderTable(app, r) { }); table.appendChild(tbody); wrap.appendChild(table); - if (rows.length > VIS_CAP) { + if (rows.length > cap) { wrap.appendChild(h('div', { style: { padding: '10px 14px', fontSize: '11px', color: 'var(--fg-faint)', fontFamily: 'var(--mono)', borderTop: '1px solid var(--border)' }, - }, '… + ' + (rows.length - VIS_CAP) + ' more rows truncated for display.')); + }, '… + ' + (rows.length - cap) + ' more rows truncated for display.')); } return wrap; } diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index abcde45..61ac93c 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -73,6 +73,7 @@ export function makeApp(over = {}) { formatQuery: vi.fn(), explainQuery: vi.fn(), setExplainView: vi.fn(), + setResultRowLimit: vi.fn(), showSchemaGraph: vi.fn(), expandSchemaGraph: vi.fn(), openNodeDetail: vi.fn(), diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index b8b2961..6635584 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -475,6 +475,45 @@ describe('query run', () => { await app.actions.run(); expect(app.activeTab().result.rawFormat).toBe('JSON'); // FORMAT clause, not the EXPLAIN default }); + const runUrl = (e, re) => e.fetch.mock.calls.findLast((c) => re.test((c[1] && c[1].body) || ''))[0]; + it('caps a normal SELECT server-side and trims block-boundary overage (flagging capped)', async () => { + const { app, e } = appForRun([ + [(u, sql) => /SELECT 1/.test(sql), resp({ body: streamBody([ + '{"meta":[{"name":"a","type":"UInt8"}]}\n', + '{"row":{"a":"1"}}\n', '{"row":{"a":"2"}}\n', '{"row":{"a":"3"}}\n', // overage past the cap of 2 + ]) })], + ]); + app.state.resultRowLimit = 2; + app.activeTab().sql = 'SELECT 1'; + await app.actions.run(); + const url = runUrl(e, /SELECT 1/); + expect(url).toContain('max_result_rows=2'); + expect(url).toContain('result_overflow_mode=break'); + expect(app.activeTab().result.rows).toEqual([['1'], ['2']]); // overage trimmed client-side + expect(app.activeTab().result.capped).toBe(true); + }); + it('does not cap EXPLAIN/ESTIMATE runs even though ESTIMATE streams as Table', async () => { + const { app, e } = appForRun([ + [(u, sql) => /ESTIMATE/.test(sql), resp({ body: streamBody(['{"meta":[{"name":"rows","type":"UInt64"}]}\n', '{"row":{"rows":"42"}}\n']) })], + ]); + app.state.resultRowLimit = 100; + app.activeTab().sql = 'EXPLAIN ESTIMATE SELECT 1'; + await app.actions.run(); + expect(runUrl(e, /ESTIMATE/)).not.toContain('max_result_rows'); + expect(app.activeTab().result.capped).toBe(false); + }); + it('setResultRowLimit persists the normalized preference and re-runs with the new cap', async () => { + const { app, e } = appForRun([ + [(u, sql) => /SELECT 1/.test(sql), resp({ body: streamBody(['{"meta":[{"name":"a","type":"UInt8"}]}\n', '{"row":{"a":"1"}}\n']) })], + ]); + app.activeTab().sql = 'SELECT 1'; + await app.actions.setResultRowLimit(99); // not an option → snaps back to the default 500 + expect(app.state.resultRowLimit).toBe(500); + expect(globalThis.localStorage.getItem('asb:resultRowLimit')).toBe('500'); + await app.actions.setResultRowLimit(1000); + expect(app.state.resultRowLimit).toBe(1000); + expect(runUrl(e, /SELECT 1/)).toContain('max_result_rows=1000'); // re-ran with the new cap + }); }); describe('formatQuery', () => { diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index 72af692..b490517 100644 --- a/tests/unit/ch-client.test.js +++ b/tests/unit/ch-client.test.js @@ -306,6 +306,16 @@ describe('runQuery', () => { await runQuery(raw, 'x', { format: 'TSV' }); expect(raw.fetch.mock.calls[0][0]).toContain('wait_end_of_query=1'); }); + it('adds the server-side row cap when resultRowLimit is set; omits it otherwise', async () => { + const capped = ctxWith(async () => streamResp(['{"row":{}}\n'])); + await runQuery(capped, 'x', { format: 'Table', resultRowLimit: 500 }); + const url = capped.fetch.mock.calls[0][0]; + expect(url).toContain('max_result_rows=500'); + expect(url).toContain('result_overflow_mode=break'); + const uncapped = ctxWith(async () => streamResp(['{"row":{}}\n'])); + await runQuery(uncapped, 'x', { format: 'Table' }); // no limit → no cap params + expect(uncapped.fetch.mock.calls[0][0]).not.toContain('max_result_rows'); + }); }); describe('killQuery', () => { diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index 90ebb18..a9f3dd7 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { renderResults, renderJson, renderTable, renderChart, colResizeWidth, openCellDetail, installChartZoomFix } from '../../src/ui/results.js'; +import { renderResults, renderJson, renderTable, renderChart, colResizeWidth, openCellDetail, installChartZoomFix, visCap } from '../../src/ui/results.js'; import { makeApp } from '../helpers/fake-app.js'; import { newResult } from '../../src/core/stream.js'; import { schemaKey } from '../../src/core/chart-data.js'; @@ -190,6 +190,54 @@ describe('renderTable', () => { }); }); +describe('result row cap', () => { + it('visCap follows the result row limit, else the 5000 fallback', () => { + expect(visCap({ rowLimit: 10000 })).toBe(10000); + expect(visCap({ rowLimit: 0 })).toBe(5000); + }); + it('renders the row-limit selector reflecting the current limit; changing it re-runs', () => { + const app = appWithResult(tableResult(), { resultRowLimit: 1000 }); + renderResults(app); + const sel = app.dom.resultsRegion.querySelector('.row-limit-select'); + expect(sel).not.toBeNull(); + expect(sel.value).toBe('1000'); + expect([...sel.options].map((o) => o.value)).toEqual(['100', '500', '1000', '5000', '10000']); + sel.value = '5000'; + sel.dispatchEvent(new Event('change', { bubbles: true })); + expect(app.actions.setResultRowLimit).toHaveBeenCalledWith(5000); + }); + it('hides the row-limit selector for EXPLAIN views', () => { + const r = newResult('Table'); + r.explainView = 'explain'; + r.rawText = 'plan'; + const app = appWithResult(r); + renderResults(app); + expect(app.dom.resultsRegion.querySelector('.row-limit-select')).toBeNull(); + }); + it('shows a "first N (capped)" badge when the result is capped, none otherwise', () => { + const r = tableResult(); + r.rowLimit = 500; + r.capped = true; + const app = appWithResult(r); + renderResults(app); + const badge = app.dom.resultsRegion.querySelector('.capped-badge'); + expect(badge.textContent).toBe('first 500 (capped)'); + // uncapped result → no badge + renderResults(appWithResult(tableResult())); + const app2 = appWithResult(tableResult()); + renderResults(app2); + expect(app2.dom.resultsRegion.querySelector('.capped-badge')).toBeNull(); + }); + it('renders rows up to the result row limit (display cap follows it)', () => { + const r = newResult('Table', 10000); + r.columns = [{ name: 'n', type: 'UInt64' }]; + r.rows = Array.from({ length: 6000 }, (_, i) => [String(i)]); + const el = renderTable(makeApp(), r); + expect(el.querySelectorAll('tbody tr')).toHaveLength(6000); // 6000 < 10000 → all shown + expect(el.textContent).not.toContain('more rows truncated'); + }); +}); + describe('column resize', () => { it('colResizeWidth converts client px via scale and clamps to the floor', () => { expect(colResizeWidth(100, 50, 1)).toBe(150); diff --git a/tests/unit/stream.test.js b/tests/unit/stream.test.js index d135512..49ae75c 100644 --- a/tests/unit/stream.test.js +++ b/tests/unit/stream.test.js @@ -10,6 +10,12 @@ describe('newResult', () => { expect(r).toMatchObject({ columns: [], rows: [], rawText: null, rawFormat: 'TSV', error: null, pct: 0 }); expect(r.progress).toEqual({ rows: 0, bytes: 0, elapsed_ns: 0 }); }); + it('defaults to an uncapped row limit', () => { + expect(newResult('Table')).toMatchObject({ rowLimit: 0, capped: false }); + }); + it('carries the row limit when given', () => { + expect(newResult('Table', 500)).toMatchObject({ rowLimit: 500, capped: false }); + }); }); describe('applyStreamLine', () => { @@ -43,6 +49,17 @@ describe('applyStreamLine', () => { expect(r.progress).toEqual({ rows: 0, bytes: 0, elapsed_ns: 0, total_rows: 0 }); expect(r.pct).toBe(0); }); + it('stops pushing rows at the cap and flags capped (trims block-boundary overage)', () => { + const r = newResult('Table', 2); + applyStreamLine({ meta: [{ name: 'a', type: 'UInt8' }] }, r); + applyStreamLine({ row: { a: '1' } }, r); + applyStreamLine({ row: { a: '2' } }, r); + expect(r.capped).toBe(false); + applyStreamLine({ row: { a: '3' } }, r); // overage past the cap → dropped + flagged + applyStreamLine({ row: { a: '4' } }, r); + expect(r.rows).toEqual([['1'], ['2']]); + expect(r.capped).toBe(true); + }); it('captures exceptions', () => { const r = newResult('Table'); applyStreamLine({ exception: 'boom' }, r);