From c43ec4ddbb51ed50a5c4e2b746b5c3e4cabc94ae Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 23 Oct 2026 00:25:30 -0700 Subject: [PATCH] test: keep explicit regressions without legacy algorithm copies (#32218) * test: keep explicit regressions without legacy algorithm copies * test: use Number.NaN in latency fixtures --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../relay/src/relay-observability.test.ts | 214 +++++------------- .../markdown-code-fence-allocation.test.ts | 22 -- .../markdown-preview-search-offsets.test.ts | 108 ++------- ...tree-lineage-expansion.performance.test.ts | 57 +---- ...foreground-process-ancestry-parity.test.ts | 176 -------------- .../foreground-process-selection.test.ts | 55 ++++- src/shared/quick-open-fuzzy-scan.test.ts | 118 +--------- 7 files changed, 144 insertions(+), 606 deletions(-) delete mode 100644 src/shared/foreground-process-ancestry-parity.test.ts diff --git a/cloud/apps/relay/src/relay-observability.test.ts b/cloud/apps/relay/src/relay-observability.test.ts index 6d73c7871e7..aad837587d4 100644 --- a/cloud/apps/relay/src/relay-observability.test.ts +++ b/cloud/apps/relay/src/relay-observability.test.ts @@ -821,109 +821,7 @@ describe('relay observability', () => { }) }) -// The pre-change implementation, kept verbatim as the differential oracle. Both -// ranks sorted their own copy and the maximum was a zero-seeded fold. -function legacyPercentile(values: number[], percentileRank: number): number { - if (values.length === 0) return 0 - const sorted = [...values].sort((left, right) => left - right) - return sorted[Math.ceil(percentileRank * sorted.length) - 1] ?? 0 -} - -function legacyLatencySummary(samples: number[]): { p50: number; p95: number; max: number } { - const round = (value: number): number => Number(value.toFixed(3)) - return { - p50: round(legacyPercentile(samples, 0.5)), - p95: round(legacyPercentile(samples, 0.95)), - max: round(samples.reduce((highest, sample) => Math.max(highest, sample), 0)) - } -} - -// `-0` and `NaN` both survive a string round trip, unlike a bare equality check. -function describeNumber(value: number): string { - return Object.is(value, -0) ? '-0' : String(value) -} - -function expectSameNumber(actual: number, expected: number, label: string): void { - expect(`${label} = ${describeNumber(actual)}`).toBe(`${label} = ${describeNumber(expected)}`) -} - -function sparseWindow(size: number, filled: Record): number[] { - const values: number[] = new Array(size) - for (const [index, value] of Object.entries(filled)) values[Number(index)] = value - return values -} - -// Lehmer generator: stays inside the safe-integer range so the window is -// byte-identical on every engine the relay runs on. -function deterministicWindow(size: number): number[] { - let seed = 20_260_912 - return Array.from({ length: size }, () => { - seed = (seed * 48_271) % 2_147_483_647 - return (seed % 4_000_000) / 1_000 - }) -} - -const DENSE_WINDOWS: Array<{ name: string; values: number[] }> = [ - { name: 'empty', values: [] }, - { name: 'single', values: [7.5] }, - { name: 'single negative', values: [-7.5] }, - { name: 'ascending', values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }, - { name: 'descending', values: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] }, - { name: 'duplicates', values: [4, 4, 4, 4, 4] }, - // The trap: a sorted last element reads -1 here, the zero-seeded fold reads 0. - { name: 'all negative', values: [-5, -1, -9, -3, -2] }, - { name: 'mixed signs', values: [-2, 3, -7, 0, 11, -0.5] }, - { name: 'signed zero', values: [0, -0, -0, 0] }, - { name: 'negative then signed zero', values: [-3, -0, -1] }, - { name: 'nan leading', values: [NaN, 5, 1, 9] }, - { name: 'nan trailing', values: [5, 1, 9, NaN] }, - { name: 'nan interleaved', values: [5, NaN, 1, NaN, 9] }, - { name: 'all nan', values: [NaN, NaN, NaN] }, - { name: 'positive infinity', values: [Infinity, 3, 1] }, - { name: 'negative infinity', values: [-Infinity, -3, -1] }, - { name: 'both infinities', values: [Infinity, -Infinity, 3, -Infinity] }, - { name: 'infinities and nan', values: [Infinity, NaN, -Infinity, 0] }, - { name: 'sub-millisecond rounding', values: [0.00049, 0.0005, 0.00051, 0.9995] }, - { name: 'reservoir sized', values: deterministicWindow(CONTROL_RTT_RESERVOIR_LIMIT) } -] - -// Holes cannot reach the recorders, so they are exercised through `percentile` -// alone — the surface `host-session-registry` also calls. -const SPARSE_WINDOWS: Array<{ name: string; values: number[] }> = [ - { name: 'all holes', values: sparseWindow(4, {}) }, - { name: 'leading hole', values: sparseWindow(5, { 3: 8, 4: 2 }) }, - { name: 'trailing hole', values: sparseWindow(5, { 0: 8, 1: 2 }) }, - { name: 'interleaved holes', values: sparseWindow(6, { 0: 3, 2: -4, 5: 1 }) }, - { name: 'holes with nan', values: sparseWindow(5, { 1: NaN, 3: 6 }) } -] - -const PERCENTILE_RANKS = [0, 0.05, 0.5, 0.9, 0.95, 0.99, 1] - -type SortWork = { sorts: number; comparisons: number; copiedElements: number } - -// Every sorted array here is a fresh spread copy, so its length is the number of -// elements copied to produce it. -function countSortWork(run: () => void): SortWork { - const work: SortWork = { sorts: 0, comparisons: 0, copiedElements: 0 } - const original = Array.prototype.sort - const patched = Array.prototype as { sort: unknown } - patched.sort = function (this: T[], compare?: (left: T, right: T) => number): T[] { - work.sorts++ - work.copiedElements += this.length - return original.call(this, (left: T, right: T) => { - work.comparisons++ - return compare ? compare(left, right) : String(left) < String(right) ? -1 : 1 - }) - } - try { - run() - } finally { - patched.sort = original - } - return work -} - -function summaryThroughFlush(samples: number[]): { p50: number; p95: number; max: number } { +function summaryThroughFlush(samples: number[]) { const entries: Array> = [] const observability = new RelayObservability( { role: 'cell', cellId: 'staging-c1', region: 'us-central1' }, @@ -933,68 +831,76 @@ function summaryThroughFlush(samples: number[]): { p50: number; p95: number; max observability.flush(counts) const entry = entries[0]! return { - p50: entry.controlRenewalLatencyMsP50 as number, - p95: entry.controlRenewalLatencyMsP95 as number, - max: entry.controlRenewalLatencyMsMax as number + p50: entry.controlRenewalLatencyMsP50, + p95: entry.controlRenewalLatencyMsP95, + max: entry.controlRenewalLatencyMsMax } } describe('latency window summarisation', () => { - it('matches the pre-change percentile on every edge-case window', () => { - let compared = 0 - for (const { name, values } of [...DENSE_WINDOWS, ...SPARSE_WINDOWS]) { - for (const rank of PERCENTILE_RANKS) { - expectSameNumber( - percentile(values, rank), - legacyPercentile(values, rank), - `${name} @ p${rank}` - ) - compared++ - } - } - expect(compared).toBe((DENSE_WINDOWS.length + SPARSE_WINDOWS.length) * PERCENTILE_RANKS.length) + it.each([ + { values: [], rank: 0.5, expected: 0 }, + { values: [10, 1, 5, 3], rank: 0.5, expected: 3 }, + { values: [10, 1, 5, 3], rank: 0.95, expected: 10 }, + { values: [10, 1, 5, 3], rank: 0, expected: 0 }, + { values: [-5, -1, -9, -3, -2], rank: 1, expected: -1 }, + { values: [0, -0, -0, 0], rank: 0.5, expected: -0 }, + { values: new Array(4), rank: 0.5, expected: 0 }, + { values: [Number.NaN, Number.NaN], rank: 1, expected: Number.NaN }, + { values: [Infinity, -Infinity], rank: 0.5, expected: -Infinity } + ])('selects the nearest rank without interpolating (case %#)', ({ values, rank, expected }) => { + expect(percentile(values, rank)).toBe(expected) }) - it('matches the pre-change p50, p95 and maximum through a flush', () => { - let compared = 0 - for (const { name, values } of DENSE_WINDOWS) { - const actual = summaryThroughFlush(values) - const expected = legacyLatencySummary(values) - expectSameNumber(actual.p50, expected.p50, `${name} p50`) - expectSameNumber(actual.p95, expected.p95, `${name} p95`) - // The zero-seeded fold, not the sorted last element: all-negative and NaN - // windows disagree between the two. - expectSameNumber(actual.max, expected.max, `${name} max`) - compared += 3 - } - expect(compared).toBe(DENSE_WINDOWS.length * 3) - // The trap, spelled out: the sorted window ends at -1 but the fold reports 0. - expect(summaryThroughFlush([-5, -1, -9, -3, -2]).max).toBe(0) - expect(Number.isNaN(summaryThroughFlush([5, NaN, 1]).max)).toBe(true) + it.each([ + { name: 'empty', values: [], expected: { p50: 0, p95: 0, max: 0 } }, + { name: 'single', values: [7.5], expected: { p50: 7.5, p95: 7.5, max: 7.5 } }, + { name: 'unsorted', values: [10, 1, 5, 3], expected: { p50: 3, p95: 10, max: 10 } }, + { name: 'duplicates', values: [4, 4, 4], expected: { p50: 4, p95: 4, max: 4 } }, + { name: 'negative', values: [-5, -1, -9, -3, -2], expected: { p50: -3, p95: -1, max: 0 } }, + { name: 'mixed signs', values: [-2, 3, -7, 0, 11, -0.5], expected: { p50: -0.5, p95: 11, max: 11 } }, + { name: 'signed zero', values: [0, -0, -0, 0], expected: { p50: 0, p95: 0, max: 0 } }, + { + name: 'NaN', + values: [Number.NaN, Number.NaN], + expected: { p50: Number.NaN, p95: Number.NaN, max: Number.NaN } + }, + { name: 'positive infinity', values: [Infinity, 3, 1], expected: { p50: 3, p95: Infinity, max: Infinity } }, + { name: 'negative infinity', values: [-Infinity, -3, -1], expected: { p50: -3, p95: -1, max: 0 } }, + { name: 'both infinities', values: [Infinity, -Infinity, 3, -Infinity], expected: { p50: -Infinity, p95: Infinity, max: Infinity } }, + { name: 'rounding', values: [0.00049, 0.0005, 0.00051, 0.9995], expected: { p50: 0.001, p95: 1, max: 1 } } + ])('publishes explicit p50, p95 and maximum for $name samples', ({ values, expected }) => { + expect(summaryThroughFlush(values)).toEqual(expected) }) - it('sorts each latency window once instead of once per rank', () => { - const samples = deterministicWindow(CONTROL_RTT_RESERVOIR_LIMIT) - const before = countSortWork(() => legacyLatencySummary(samples)) - const after = countSortWork(() => summaryThroughFlush(samples)) - - expect(before.sorts).toBe(2) - expect(after.sorts).toBe(1) - expect(before.copiedElements).toBe(2 * CONTROL_RTT_RESERVOIR_LIMIT) - expect(after.copiedElements).toBe(CONTROL_RTT_RESERVOIR_LIMIT) - // Identical input and comparator, so the dropped sort is exactly half the - // comparator calls rather than an engine-specific constant. - expect(before.comparisons).toBeGreaterThan(CONTROL_RTT_RESERVOIR_LIMIT) - expect(after.comparisons).toBe(before.comparisons / 2) + it('sorts a full latency reservoir once for all ranks', () => { + const samples = Array.from({ length: CONTROL_RTT_RESERVOIR_LIMIT }, (_, index) => + CONTROL_RTT_RESERVOIR_LIMIT - index + ) + const sort = vi.spyOn(Array.prototype, 'sort') + try { + summaryThroughFlush(samples) + const sortedWindows = [...sort.mock.contexts] + expect(sortedWindows).toHaveLength(1) + expect(sortedWindows[0]).toHaveLength(CONTROL_RTT_RESERVOIR_LIMIT) + } finally { + sort.mockRestore() + } }) it('never sorts an empty window and leaves the caller window untouched', () => { - const samples = [5, -1, NaN, 3, -0] - const before = samples.map(describeNumber) - expect(countSortWork(() => summaryThroughFlush([])).sorts).toBe(0) - expect(countSortWork(() => percentile([], 0.95)).sorts).toBe(0) - countSortWork(() => summaryThroughFlush(samples)) + const sort = vi.spyOn(Array.prototype, 'sort') + try { + summaryThroughFlush([]) + percentile([], 0.95) + expect(sort).not.toHaveBeenCalled() + } finally { + sort.mockRestore() + } + const samples = [5, -1, Number.NaN, 3, -0] + const before = [...samples] + summaryThroughFlush(samples) percentile(samples, 0.5) - expect(samples.map(describeNumber)).toEqual(before) + expect(samples).toEqual(before) }) }) diff --git a/mobile/src/components/rich-markdown/markdown-code-fence-allocation.test.ts b/mobile/src/components/rich-markdown/markdown-code-fence-allocation.test.ts index b3509fe024f..fbbacbba046 100644 --- a/mobile/src/components/rich-markdown/markdown-code-fence-allocation.test.ts +++ b/mobile/src/components/rich-markdown/markdown-code-fence-allocation.test.ts @@ -12,11 +12,6 @@ afterEach(() => { document.body.innerHTML = '' }) -function originalFence(code: string): string { - const longest = (code.match(/`+/g) ?? []).reduce((run, match) => Math.max(run, match.length), 0) - return '`'.repeat(Math.max(3, longest + 1)) -} - describe('code-block fence sizing', () => { it.each([ { code: '', length: 3 }, @@ -30,23 +25,6 @@ describe('code-block fence sizing', () => { expect(codeFenceFor(code)).toBe('`'.repeat(length)) }) - it('matches the previous result across raw UTF-16 text and consecutive calls', () => { - let seed = 0x823517 - const random = () => { - seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 - return seed - } - const atoms = ['`', '`', '````', 'a', '\n', '\r', '\u0000', '\uD800', '\uDC00', '😀', 'é', ' '] - for (let sample = 0; sample < 3000; sample += 1) { - let code = '' - const length = random() % 500 - for (let index = 0; index < length; index += 1) { - code += atoms[random() % atoms.length] - } - expect(codeFenceFor(code)).toBe(originalFence(code)) - } - }) - it('serializes an admitted editable document without collecting every backtick run', () => { document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP const posted: MobileRichMarkdownEditorMessage[] = [] diff --git a/src/renderer/src/components/editor/markdown-preview-search-offsets.test.ts b/src/renderer/src/components/editor/markdown-preview-search-offsets.test.ts index 05fa85dc195..f5e0a35da69 100644 --- a/src/renderer/src/components/editor/markdown-preview-search-offsets.test.ts +++ b/src/renderer/src/components/editor/markdown-preview-search-offsets.test.ts @@ -1,43 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { findTextMatchRanges } from './markdown-preview-search' -function referenceMatchRanges(text: string, query: string): { start: number; end: number }[] { - let normalized = '' - let originalOffset = 0 - const starts: number[] = [] - const ends: number[] = [] - for (const char of text) { - const lowercase = char.toLocaleLowerCase() - for (let index = 0; index < lowercase.length; index += 1) { - starts.push(originalOffset) - ends.push(originalOffset + char.length) - } - normalized += lowercase - originalOffset += char.length - } - const normalizedQuery = Array.from(query, (char) => char.toLocaleLowerCase()).join('') - const matches: { start: number; end: number }[] = [] - let searchStart = 0 - while (searchStart <= normalized.length - normalizedQuery.length) { - const start = normalized.indexOf(normalizedQuery, searchStart) - if (start === -1) { - break - } - const end = start + normalizedQuery.length - matches.push({ start: starts[start] ?? text.length, end: ends[end - 1] ?? text.length }) - searchStart = end + (normalizedQuery.length === 0 ? 1 : 0) - } - return matches -} - -function createRandom(seed: number): (max: number) => number { - let state = seed - return (max) => { - state = (Math.imul(state, 1664525) + 1013904223) >>> 0 - return state % max - } -} - function mockLowercaseLocale(locale: string): void { const lowercase = String.prototype.toLocaleLowerCase vi.spyOn(String.prototype, 'toLocaleLowerCase').mockImplementation(function (this: string) { @@ -57,9 +20,16 @@ describe('case-insensitive Markdown search offsets', () => { ]) }) - it.each(['İ', '😊', '𐐀'])('keeps offsets across an ordinary prefix and %s', (character) => { + it.each([ + { character: 'İ', first: 9, second: 18 }, + { character: '😊', first: 10, second: 20 }, + { character: '𐐀', first: 10, second: 20 } + ])('keeps offsets across an ordinary prefix and $character', ({ character, first, second }) => { const text = `Prefix ${character} NEEDLE ${character} needle` - expect(findTextMatchRanges(text, 'needle')).toEqual(referenceMatchRanges(text, 'needle')) + expect(findTextMatchRanges(text, 'needle')).toEqual([ + { start: first, end: first + 6 }, + { start: second, end: second + 6 } + ]) }) it('preserves whole code-point ranges for lone surrogate queries', () => { @@ -75,49 +45,19 @@ describe('case-insensitive Markdown search offsets', () => { ]) }) - it.each(['en-US', 'tr', 'az', 'lt', 'el'])( - 'preserves every ASCII character in locale %s', - (locale) => { - mockLowercaseLocale(locale) - const ascii = Array.from({ length: 128 }, (_, index) => String.fromCharCode(index)).join('') - const text = `${ascii} IJI iIj ${ascii}` - for (const query of [...ascii, 'IJI', 'iij', 'j']) { - expect(findTextMatchRanges(text, query)).toEqual(referenceMatchRanges(text, query)) - } - } - ) - - it.each(['en-US', 'tr', 'az', 'lt', 'el'])( - 'matches reference code-point folding in locale %s', - (locale) => { - mockLowercaseLocale(locale) - const random = createRandom(101) - const characters = [ - 'a', - 'B', - ' ', - 'İ', - 'I', - 'ı', - '\u0307', - 'Σ', - 'σ', - 'ς', - '𐐀', - '𐐨', - '😊', - '\ud800', - '\udc00' - ] - for (let sample = 0; sample < 250; sample += 1) { - let text = '' - for (let index = 0; index < 100; index += 1) { - text += characters[random(characters.length)] - } - const start = random(text.length) - const query = text.slice(start, start + random(4) + 1) - expect(findTextMatchRanges(text, query)).toEqual(referenceMatchRanges(text, query)) - } - } - ) + it.each([ + { locale: 'en-US', dotted: [0, 2, 4], dotless: [6] }, + { locale: 'tr', dotted: [2, 4], dotless: [0, 6] }, + { locale: 'az', dotted: [2, 4], dotless: [0, 6] }, + { locale: 'lt', dotted: [0, 2, 4], dotless: [6] }, + { locale: 'el', dotted: [0, 2, 4], dotless: [6] } + ])('respects dotted and dotless I in $locale', ({ locale, dotted, dotless }) => { + mockLowercaseLocale(locale) + expect(findTextMatchRanges('I i İ ı', 'i')).toEqual( + dotted.map((start) => ({ start, end: start + 1 })) + ) + expect(findTextMatchRanges('I i İ ı', 'ı')).toEqual( + dotless.map((start) => ({ start, end: start + 1 })) + ) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts index c6f6fdf11a1..b90bde7c47f 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts @@ -1,38 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { - expandDraggedWorktreeIdsForVisibleLineage, - type WorktreeDragLineageRow -} from './worktree-manual-order' - -// Preserve the original traversal as an independent ordering oracle. -function referenceExpansion( - rows: readonly WorktreeDragLineageRow[], - draggedIds: readonly string[] -) { - const dragged = new Set(draggedIds) - const expanded = new Set(draggedIds) - const rowIds = new Set(rows.map((row) => row.worktreeId)) - for (let index = 0; index < rows.length; index++) { - const row = rows[index] - if (!dragged.has(row.worktreeId)) { - continue - } - for (let cursor = index + 1; cursor < rows.length; cursor++) { - const child = rows[cursor] - if (child.depth <= row.depth) { - break - } - expanded.add(child.worktreeId) - } - } - const result = rows.filter((row) => expanded.has(row.worktreeId)).map((row) => row.worktreeId) - for (const id of draggedIds) { - if (!rowIds.has(id) && !result.includes(id)) { - result.push(id) - } - } - return result -} +import { expandDraggedWorktreeIdsForVisibleLineage } from './worktree-manual-order' describe('visible lineage expansion scaling', () => { it('reads each depth once even when every ancestor is selected', () => { @@ -45,9 +12,6 @@ describe('visible lineage expansion scaling', () => { } })) const selected = rows.map((row) => row.worktreeId) - expect(referenceExpansion(rows, selected)).toEqual(selected) - expect(depthReads).toBe(rows.length * (rows.length - 1)) - depthReads = 0 expect(expandDraggedWorktreeIdsForVisibleLineage(rows, selected)).toEqual(selected) expect(depthReads).toBe(rows.length) }) @@ -66,23 +30,4 @@ describe('visible lineage expansion scaling', () => { expect(result).toEqual(selected) expect(calls).toBe(0) }) - - it('matches the previous algorithm across duplicate rows, missing IDs and depth boundaries', () => { - let seed = 12345 - function random(limit: number): number { - seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 - return seed % limit - } - const depths = [-1, 0, 1, 2, 3, 20, Infinity, -Infinity, Number.NaN] - for (let sample = 0; sample < 500; sample++) { - const rows = Array.from({ length: random(40) }, () => ({ - worktreeId: `wt-${random(20)}`, - depth: depths[random(depths.length)] - })) - const selected = Array.from({ length: random(20) }, () => `wt-${random(25)}`) - expect(expandDraggedWorktreeIdsForVisibleLineage(rows, selected)).toEqual( - referenceExpansion(rows, selected) - ) - } - }) }) diff --git a/src/shared/foreground-process-ancestry-parity.test.ts b/src/shared/foreground-process-ancestry-parity.test.ts deleted file mode 100644 index ae2741084ba..00000000000 --- a/src/shared/foreground-process-ancestry-parity.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { expect, it } from 'vitest' -import { - selectForegroundProcessCandidate, - type ForegroundProcessCandidate -} from './foreground-process-selection' -import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' - -/** The pre-memo lineage walk, with a step cap standing in for its missing cycle guard. */ -function referenceIsAncestorOrSelf( - ancestorPid: number, - descendant: ForegroundProcessCandidate, - byPid: ReadonlyMap -): boolean { - let currentPid = descendant.pid - for (let steps = 0; steps <= byPid.size + 1; steps += 1) { - if (currentPid === ancestorPid) { - return true - } - const current = byPid.get(currentPid) - if (!current) { - return false - } - currentPid = current.ppid - } - // Only reachable on a ppid cycle, where the original spun forever. - return false -} - -function referenceSelect( - candidates: readonly ForegroundProcessCandidate[], - ancestryCandidates: readonly ForegroundProcessCandidate[] = candidates -): ForegroundProcessCandidate | null { - const recognized = candidates.flatMap((candidate) => { - const agent = recognizeAgentProcessFromCommandLine(candidate.command) - return agent ? [{ candidate, agent }] : [] - }) - if (recognized.length === 0) { - return null - } - const names = new Set(recognized.map((entry) => entry.agent.agent)) - if (names.size > 1) { - const byPid = new Map(ancestryCandidates.map((candidate) => [candidate.pid, candidate])) - const outer = [...recognized].sort( - (left, right) => left.candidate.depth - right.candidate.depth - )[0] - if ( - !outer || - !recognized.every((entry) => - referenceIsAncestorOrSelf(outer.candidate.pid, entry.candidate, byPid) - ) - ) { - return null - } - return outer.candidate - } - const score = (candidate: ForegroundProcessCandidate): number => - (candidate.stat?.includes('+') ? 10_000 : 0) + candidate.depth - return recognized.reduce((best, current) => - score(current.candidate) > score(best.candidate) ? current : best - ).candidate -} - -function makeRandom(seed: number): () => number { - let state = seed >>> 0 - return () => { - state = (state * 1664525 + 1013904223) >>> 0 - return state / 0x100000000 - } -} - -const COMMANDS = ['claude', 'codex', 'opencode', 'bash -lc build', 'node server.js'] - -/** A random process table: some rows reparented, some parents missing, some cycles. */ -function makeTable(random: () => number, size: number): ForegroundProcessCandidate[] { - const rows: ForegroundProcessCandidate[] = [] - for (let index = 0; index < size; index += 1) { - const pid = index + 1 - const roll = random() - let ppid: number - if (index === 0) { - ppid = 0 - } else if (roll < 0.15) { - ppid = 9000 + index // parent absent from the table - } else if (roll < 0.25) { - ppid = 1 + Math.floor(random() * size) // arbitrary reparent, may form a cycle - } else { - ppid = index // straight chain - } - rows.push({ - pid, - ppid, - depth: index, - stat: random() < 0.5 ? 'S+' : 'S', - command: COMMANDS[Math.floor(random() * COMMANDS.length)]! - }) - } - return rows -} - -it('picks the same foreground agent as the unmemoized lineage walk', () => { - let multiAgentCases = 0 - let selectedCases = 0 - for (let seed = 1; seed <= 3000; seed += 1) { - const random = makeRandom(seed) - const table = makeTable(random, 1 + Math.floor(random() * 10)) - // Also exercise the split candidate/ancestry inputs the batch caller uses. - const foreground = table.filter((_, index) => index % 3 !== 2) - for (const [candidates, ancestry] of [ - [table, table], - [foreground, table] - ] as const) { - const actual = selectForegroundProcessCandidate(candidates, ancestry) - const expected = referenceSelect(candidates, ancestry) - expect(actual?.candidate ?? null, `seed ${seed}`).toEqual(expected) - if ( - new Set(candidates.map((row) => recognizeAgentProcessFromCommandLine(row.command)?.agent)) - .size > 2 - ) { - multiAgentCases += 1 - } - if (actual) { - selectedCases += 1 - } - } - } - // The mixed-agent ancestry branch (the only path the memo touches) must be hit. - expect(multiAgentCases).toBeGreaterThan(500) - expect(selectedCases).toBeGreaterThan(500) -}) - -// The pre-memo walk had no cycle guard, so a ppid loop that never reaches the outer -// agent spun forever and hung the caller reporting the foreground process. -it('terminates on a ppid cycle that never reaches the outer agent', () => { - const cycle: ForegroundProcessCandidate[] = [ - { pid: 1, ppid: 0, depth: 0, stat: 'S+', command: 'claude' }, - { pid: 2, ppid: 3, depth: 1, stat: 'S+', command: 'codex' }, - { pid: 3, ppid: 2, depth: 2, stat: 'S+', command: 'bash -lc build' } - ] - expect(selectForegroundProcessCandidate(cycle)).toBeNull() -}) - -it('reflects a reparent, a spawn and an exit on the next capture', () => { - const shell: ForegroundProcessCandidate = { - pid: 10, - ppid: 1, - depth: 0, - stat: 'S+', - command: 'claude' - } - const helper: ForegroundProcessCandidate = { - pid: 11, - ppid: 10, - depth: 1, - stat: 'S+', - command: 'codex' - } - // Nested lineage: the outer agent wins. - expect(selectForegroundProcessCandidate([shell, helper])?.candidate.pid).toBe(10) - - // Reparent the helper to a pid outside the capture: the lineage no longer holds. - const reparented = { ...helper, ppid: 999 } - expect(selectForegroundProcessCandidate([shell, reparented])).toBeNull() - - // Spawn a sibling agent under an unrelated parent: still untrustworthy. - const sibling: ForegroundProcessCandidate = { - pid: 12, - ppid: 1, - depth: 1, - stat: 'S+', - command: 'opencode' - } - expect(selectForegroundProcessCandidate([shell, helper, sibling])).toBeNull() - - // The sibling exits: the surviving nested lineage resolves again on the new capture. - expect(selectForegroundProcessCandidate([shell, helper])?.candidate.pid).toBe(10) -}) diff --git a/src/shared/foreground-process-selection.test.ts b/src/shared/foreground-process-selection.test.ts index 56cffb0d16a..d034f1ccff2 100644 --- a/src/shared/foreground-process-selection.test.ts +++ b/src/shared/foreground-process-selection.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { selectForegroundProcessCandidate } from './foreground-process-selection' +import { + selectForegroundProcessCandidate, + type ForegroundProcessCandidate +} from './foreground-process-selection' describe('selectForegroundProcessCandidate', () => { it('keeps a recognized ancestor over a different agent helper below a non-agent', () => { @@ -37,14 +40,17 @@ describe('selectForegroundProcessCandidate', () => { expect(selectForegroundProcessCandidate(candidates)).toBeNull() }) - it('keeps the deepest process when one recognized agent owns the lineage', () => { + it.each([ + { helperStat: 'S+', selectedPid: 102 }, + { helperStat: 'S', selectedPid: 101 } + ])('prefers foreground processes, then depth ($helperStat)', ({ helperStat, selectedPid }) => { const candidates = [ { pid: 101, ppid: 100, depth: 1, stat: 'S+', command: 'node /opt/bin/codex' }, - { pid: 102, ppid: 101, depth: 2, stat: 'S+', command: '/opt/vendor/bin/codex' } + { pid: 102, ppid: 101, depth: 2, stat: helperStat, command: '/opt/vendor/bin/codex' } ] expect(selectForegroundProcessCandidate(candidates)).toMatchObject({ - candidate: { pid: 102 }, + candidate: { pid: selectedPid }, recognized: { agent: 'codex' } }) }) @@ -65,3 +71,44 @@ it('memoizes shared ancestry while validating a long agent/helper lineage', () = expect(selectForegroundProcessCandidate(candidates)?.candidate.pid).toBe(1) expect(reads).toBeLessThanOrEqual(1000) }) + +it('terminates on a ppid cycle that never reaches the outer agent', () => { + const cycle: ForegroundProcessCandidate[] = [ + { pid: 1, ppid: 0, depth: 0, stat: 'S+', command: 'claude' }, + { pid: 2, ppid: 3, depth: 1, stat: 'S+', command: 'codex' }, + { pid: 3, ppid: 2, depth: 2, stat: 'S+', command: 'bash -lc build' } + ] + expect(selectForegroundProcessCandidate(cycle)).toBeNull() +}) + +it('does not reuse ancestry after reparenting, spawning or exit', () => { + const shell: ForegroundProcessCandidate = { + pid: 10, + ppid: 1, + depth: 0, + stat: 'S+', + command: 'claude' + } + const helper: ForegroundProcessCandidate = { + pid: 11, + ppid: 10, + depth: 1, + stat: 'S+', + command: 'codex' + } + expect(selectForegroundProcessCandidate([shell, helper])?.candidate.pid).toBe(10) + + const reparented = { ...helper, ppid: 999 } + expect(selectForegroundProcessCandidate([shell, reparented])).toBeNull() + + const sibling: ForegroundProcessCandidate = { + pid: 12, + ppid: 1, + depth: 1, + stat: 'S+', + command: 'opencode' + } + expect(selectForegroundProcessCandidate([shell, helper, sibling])).toBeNull() + + expect(selectForegroundProcessCandidate([shell, helper])?.candidate.pid).toBe(10) +}) diff --git a/src/shared/quick-open-fuzzy-scan.test.ts b/src/shared/quick-open-fuzzy-scan.test.ts index 1bf748a323f..2d4a1ad79ed 100644 --- a/src/shared/quick-open-fuzzy-scan.test.ts +++ b/src/shared/quick-open-fuzzy-scan.test.ts @@ -1,35 +1,12 @@ import { expect, it } from 'vitest' -import { compareFileNames } from './file-name-sort' -import { - prepareQuickOpenFiles, - QuickOpenPathRanker, - rankQuickOpenFiles, - type QuickOpenIndexedFile -} from './quick-open-path-search' +import { prepareQuickOpenFiles, rankQuickOpenFiles } from './quick-open-path-search' -function referenceScore(query: string, file: QuickOpenIndexedFile): number | null { - let qi = 0 - let score = 0 - let lastMatch = -1 - for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) { - if (file.lowerPath[ti] !== query[qi]) { - continue - } - score += lastMatch === -1 ? 0 : ti - lastMatch - 1 - if (ti > 0 && '/.-'.includes(file.lowerPath[ti - 1])) { - score -= 5 - } - lastMatch = ti - qi++ - } - if (qi < query.length) { - return null - } - return score - (file.lowerFilename.includes(query) ? 100 : 0) -} - -it.each(['az', 'aq'])('skips nonmatching path spans for %s', (query) => { - const [prepared] = prepareQuickOpenFiles([`a/${'x'.repeat(4000)}/z.ts`]) +it.each([ + { query: 'az', score: 3997 }, + { query: 'aq', score: null } +])('skips nonmatching path spans for $query', ({ query, score }) => { + const path = `a/${'x'.repeat(4000)}/z.ts` + const [prepared] = prepareQuickOpenFiles([path]) let pathReads = 0 const measured = { ...prepared, @@ -38,85 +15,6 @@ it.each(['az', 'aq'])('skips nonmatching path spans for %s', (query) => { return prepared.lowerPath } } - expect(rankQuickOpenFiles(query, [measured])).toEqual(rankQuickOpenFiles(query, [prepared])) + expect(rankQuickOpenFiles(query, [measured])).toEqual(score === null ? [] : [{ path, score }]) expect(pathReads).toBeLessThanOrEqual(20) }) - -it('preserves code-unit scores and ordering for generated Unicode paths and queries', () => { - const alphabet = [ - 'a', - 'b', - 'c', - '/', - '\\', - '.', - '-', - '2', - '0', - 'é', - 'e\u0301', - 'İ', - '😀', - '\ud800', - '\udc00' - ] - let seed = 43 - const next = () => { - seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 - return seed - } - const paths = ['a.../b', 'a/x/z', 'a/z', '😀.ts', 'a'.repeat(110), 'aa', 'same', 'same', ''] - for (let index = 0; index < 1000; index++) { - const length = next() % 80 - let path = '' - for (let offset = 0; offset < length; offset++) { - path += alphabet[next() % alphabet.length] - } - paths.push(path) - } - const files = prepareQuickOpenFiles(paths) - const queries = [ - '', - ' ', - 'az', - 'ab', - 'a', - 'aa', - 'q', - '😀', - '\ud800', - '\udc00', - 'é', - 'e\u0301', - 'İ', - 'a\\b', - ' A ' - ] - for (let index = 0; index < 100; index++) { - queries.push(`${alphabet[next() % alphabet.length]}${alphabet[next() % alphabet.length]}`) - } - for (const query of queries) { - // Separator queries now include word-boundary matches. - if (/[-_]/.test(query)) { - continue - } - const normalized = query.trim().replace(/\\/g, '/').toLowerCase() - const expected = files - .map((file) => ({ ...file, score: normalized ? referenceScore(normalized, file) : 0 })) - .filter((file): file is typeof file & { score: number } => file.score !== null) - .sort( - (a, b) => - a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex - ) - for (const limit of [1, 16, 50]) { - const selected = expected.slice(0, limit).map(({ path, score }) => ({ path, score })) - expect(rankQuickOpenFiles(query, files, limit)).toEqual(selected) - const streamed = new QuickOpenPathRanker(query, limit) - paths.forEach((path) => streamed.consider(path)) - expect(streamed.result()).toEqual({ - paths: selected.map((entry) => entry.path), - totalCount: expected.length - }) - } - } -})