diff --git a/.gitignore b/.gitignore index 81fe7a147..32e895564 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ .lock-wscript lib/ out/ +out-benchmark/ out-demo/ out-test/ out-esbuild/ diff --git a/addons/addon-search/benchmark/SearchAddon.benchmark.ts b/addons/addon-search/benchmark/SearchAddon.benchmark.ts new file mode 100644 index 000000000..b3844d83b --- /dev/null +++ b/addons/addon-search/benchmark/SearchAddon.benchmark.ts @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { perfContext, before, RuntimeCase } from 'xterm-benchmark'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { Terminal } from 'browser/public/Terminal'; +import { SearchAddon } from 'SearchAddon'; +import type { ISearchOptions } from '@xterm/addon-search'; + +const enum Constants { + COLS = 120, + ROWS = 40, + SCROLLBACK = 8000, + NAVIGATION_ITERATIONS = 250, + INCREMENTAL_ITERATIONS = 75, + DECORATION_REFRESH_ITERATIONS = 25 +} + +const DECORATION_OPTIONS: ISearchOptions = { + decorations: { + activeMatchBackground: '#0000ff', + activeMatchColorOverviewRuler: '#ff0000', + matchBackground: '#ffff00', + matchBorder: '#000000', + matchOverviewRuler: '#00ffff' + } +}; + +class TestTerminal extends Terminal { + public writeSync(data: string): void { + (this as any)._core.writeSync(data); + } + + public override select(_column: number, _row: number, _length: number): void { + // SearchAddon always selects matches; skip selection internals in headless benchmark runs. + } + + public override clearSelection(): void { + // SearchAddon clears the previous selection before selecting another match. + } + + public override scrollLines(_amount: number): void { + // No viewport scrolling is needed for API runtime measurement. + } +} + +function buildRealWorldBuffer(): string { + const fixture = readFileSync(resolve(__dirname, '../../fixtures/issue-2444'), 'utf8') + .replace(/\r?\n/g, '\r\n'); + const lines: string[] = [fixture]; + for (let i = 0; i < 2500; i++) { + const minute = (i % 60).toString().padStart(2, '0'); + const second = ((i * 7) % 60).toString().padStart(2, '0'); + lines.push(`2026-05-25T16:${minute}:${second}Z INFO [api-${i % 8}] requestId=req-${i.toString(16).padStart(6, '0')} path=/v1/projects/${i % 120}/search?q=opencv status=200 durationMs=${10 + (i % 90)}`); + if (i % 5 === 0) { + lines.push(`2026-05-25T16:${minute}:${second}Z WARN [worker-${i % 4}] slow query detected query="SELECT * FROM runs WHERE status='pending'" elapsedMs=${500 + (i % 1200)}`); + } + if (i % 11 === 0) { + lines.push(`2026-05-25T16:${minute}:${second}Z ERROR [worker-${i % 4}] command failed: package.json parse error at column ${10 + (i % 70)}; retrying in ${2 + (i % 7)}s`); + } + if (i % 17 === 0) { + lines.push(`https://example.internal/run/${i}/details?source=terminal-search&state=error`); + } + } + return lines.join('\r\n') + '\r\n'; +} + +perfContext('SearchAddon API on real-world terminal content', () => { + const bufferContent = buildRealWorldBuffer(); + + perfContext('findNext/plain navigation', () => { + let terminal: TestTerminal; + let search: SearchAddon; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon(); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) { + if (search.findNext('opencv')) { + foundCount++; + } + } + return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('findNext/regex navigation', () => { + let terminal: TestTerminal; + let search: SearchAddon; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon(); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) { + if (search.findNext('https://example\\.internal/run/\\d+/details\\?source=terminal-search', { regex: true })) { + foundCount++; + } + } + return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('findPrevious/incremental typing flow', () => { + let terminal: TestTerminal; + let search: SearchAddon; + const terms = ['e', 'er', 'err', 'erro', 'error']; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon(); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.INCREMENTAL_ITERATIONS; i++) { + for (const term of terms) { + if (search.findPrevious(term, { incremental: true })) { + foundCount++; + } + } + } + return { payloadSize: Constants.INCREMENTAL_ITERATIONS * terms.length, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('findNext/decorations refresh', () => { + let terminal: TestTerminal; + let search: SearchAddon; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon({ highlightLimit: 1500 }); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.DECORATION_REFRESH_ITERATIONS; i++) { + search.clearDecorations(); + const term = i % 2 === 0 ? 'WARN' : 'ERROR'; + if (search.findNext(term, DECORATION_OPTIONS)) { + foundCount++; + } + } + return { payloadSize: Constants.DECORATION_REFRESH_ITERATIONS, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); +}); diff --git a/addons/addon-search/benchmark/benchmark.json b/addons/addon-search/benchmark/benchmark.json new file mode 100644 index 000000000..1333eac88 --- /dev/null +++ b/addons/addon-search/benchmark/benchmark.json @@ -0,0 +1,19 @@ +{ + "APP_PATH": ".benchmark", + "evalConfig": { + "tolerance": { + "*": [0.75, 1.5], + "*.dev": [0.01, 1.5], + "*.cv": [0.01, 1.5], + "EscapeSequenceParser.benchmark.js.*.averageThroughput.mean": [0.9, 5] + }, + "skip": [ + "*.median", + "*.runs", + "*.dev", + "*.cv", + "EscapeSequenceParser.benchmark.js.*.averageRuntime", + "Terminal.benchmark.js.*.averageRuntime" + ] + } +} diff --git a/addons/addon-search/benchmark/tsconfig.json b/addons/addon-search/benchmark/tsconfig.json new file mode 100644 index 000000000..58afd6f94 --- /dev/null +++ b/addons/addon-search/benchmark/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "es2021" + ], + "rootDir": "..", + "outDir": "../out-benchmark", + "types": ["../../../node_modules/@types/node"], + "moduleResolution": "node", + "strict": false, + "target": "es2021", + "module": "commonjs", + "paths": { + "common/*": ["../../../src/common/*"], + "browser/*": ["../../../src/browser/*"], + "SearchAddon": ["../src/SearchAddon"], + "@xterm/addon-search": [ + "../typings/addon-search.d.ts" + ] + } + }, + "include": ["../**/*", "../../../typings/xterm.d.ts"], + "exclude": ["../../../**/*test.ts", "../../**/*api.ts"], + "references": [ + { "path": "../../../src/common" }, + { "path": "../../../src/browser" } + ] +} diff --git a/addons/addon-search/package.json b/addons/addon-search/package.json index ebec5a8b8..bc9599eed 100644 --- a/addons/addon-search/package.json +++ b/addons/addon-search/package.json @@ -19,6 +19,9 @@ "prepackage": "../../node_modules/.bin/tsgo -p .", "package": "../../node_modules/.bin/webpack", "prepublishOnly": "npm run package", - "start": "node ../../demo/start" + "start": "node ../../demo/start", + "benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js", + "benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js", + "benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js" } } diff --git a/addons/addon-search/tsconfig.json b/addons/addon-search/tsconfig.json index 2d820dd1a..0e7b5c350 100644 --- a/addons/addon-search/tsconfig.json +++ b/addons/addon-search/tsconfig.json @@ -3,6 +3,7 @@ "include": [], "references": [ { "path": "./src" }, - { "path": "./test" } + { "path": "./test" }, + { "path": "./benchmark" } ] }