From fcd60a03f8d05fe8ffdb4ca35b926e80285f579a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:56:49 -0700 Subject: [PATCH] Keep long live session logs stable while they update (#8432) * Keep long live session logs stable while updating * fix(editor): normalize content before append sync * chore(editor): export e2e probe type and link gate motivator Post-review cleanup: env.d.ts referenced the probe's method shape as an inline literal, so a probe rename would only surface in the e2e spec; the new reliability gate's motivatingLinks pointed at the repo root. --------- Co-authored-by: Brennan Benson --- config/reliability-gates.jsonc | 103 ++++- .../EditorContent.monaco-lifecycle.test.tsx | 165 ++++++++ .../src/components/editor/EditorContent.tsx | 6 +- .../MonacoEditor.content-owner.test.tsx | 62 +++ .../src/components/editor/MonacoEditor.tsx | 7 +- .../editor/monaco-content-sync.bench.ts | 85 ++++ .../editor/monaco-content-sync.test.ts | 168 ++++++++ .../components/editor/monaco-content-sync.ts | 53 ++- .../src/components/editor/monaco-e2e-probe.ts | 99 +++++ src/renderer/src/env.d.ts | 2 + .../agent-session-log-tail-stability.spec.ts | 394 ++++++++++++++++++ 11 files changed, 1135 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx create mode 100644 src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx create mode 100644 src/renderer/src/components/editor/monaco-content-sync.bench.ts create mode 100644 src/renderer/src/components/editor/monaco-content-sync.test.ts create mode 100644 src/renderer/src/components/editor/monaco-e2e-probe.ts create mode 100644 tests/e2e/agent-session-log-tail-stability.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index b9b9ff244..7133fd9fb 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-11", + "updatedAt": "2026-07-12", "policy": { "maturityLevels": [ "experimental", @@ -16,6 +16,107 @@ } }, "gates": [ + { + "id": "editor.live-log-append-stability", + "title": "Long live session logs retain their Monaco viewport while appending", + "maturity": "experimental", + "protection": "partial", + "owner": "editor-runtime", + "layer": "renderer-electron-contract", + "surfaces": ["Agent Session History View Log", "Monaco external-content reconciliation", "renderer crash containment"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Focused tests and real-Monaco 9/50 MiB benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and the measured 2,775,880px legacy-failure geometry, then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/8432"], + "invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; arbitrary rewrites continue to replace the model content.", + "oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, and full replacement for non-appends. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, forcing GC and event-loop settlement between every arm. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, renderer survival, and forced-GC heap/native-memory budgets.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.test.ts src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", + "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.bench.ts --pool=threads", + "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/editor/monaco-content-sync.test.ts", + "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx", + "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", + "src/renderer/src/components/editor/monaco-content-sync.bench.ts", + "tests/e2e/agent-session-log-tail-stability.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/editor/monaco-content-sync.test.ts", + "assertions": [ + "append-only drift reads the current model once and inserts only at the previous model end", + "identical content emits no edit and non-append drift retains full replacement plus undo stops", + "a stale retained target model reconciles on mount without explicit undo stops while prior-path content and undo history remain isolated" + ] + }, + { + "file": "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx", + "assertions": ["the Monaco wrapper receives defaultValue and no controlled value prop"] + }, + { + "file": "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", + "assertions": ["a same-pane path switch unmounts the prior outer Monaco before real mount reconciliation refreshes the stale target; the prior retained model content and undo sentinel remain untouched"] + }, + { + "file": "src/renderer/src/components/editor/monaco-content-sync.bench.ts", + "assertions": ["with forced GC and deterministic settlement between every arm, fresh real-Monaco 9 MiB and 50 MiB models alternate 30 append and 30 replacement samples after five warmups; append p95 stays below 50/100ms and at least 2x faster"] + }, + { + "file": "tests/e2e/agent-session-log-tail-stability.spec.ts", + "assertions": [ + "production Agent Session History opens a synthetic 9 MiB View Log at the measured 2,775,880px legacy-failure geometry", + "an executable e2e-only legacy setValue control disrupts selection/Find/anchor state at each fixed-geometry five-second sample, then restores the equivalent model state before the fixed arm", + "three alternating watcher suffix appends preserve visible ranges, selection, scroll offset, Find open/query/active-match state, and exact suffix content", + "the renderer remains responsive with no render-process-gone event and forced-GC JS-heap/working-set/private-memory peak and retained budgets hold against paired legacy controls" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.test.ts src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", + "result": "passed", + "durationSeconds": 5, + "summary": "Focused editor ownership, edit-shape, mount reconciliation, lifecycle-key, and actual same-pane retained-model remount tests passed." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.bench.ts --pool=threads", + "result": "passed", + "durationSeconds": 75, + "summary": "Forced-GC, settled, alternating fresh-model Monaco p95: 9 MiB append 4.02-5.51ms versus replacement 81.42-83.76ms; 50 MiB append 22.72-26.60ms versus replacement 445.11-449.21ms." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 78, + "summary": "The production View Log journey alternated retained e2e-only legacy-red controls with fixed appends from restored equivalent state; every control detected instability while the fixed path retained viewport, selection, complete Find state, renderer liveness, and normalized forced-GC/native memory budgets." + } + ], + "runtimeBudget": { "p95Seconds": 600, "scope": "local focused renderer tests plus one Electron production-journey scenario" }, + "flakeHistory": { "status": "unknown", "evidence": "New deterministic gate with local macOS passes; CI soak history is not yet available." }, + "redGreenEvidence": { "status": "complete", "evidence": "The retained Electron gate itself executes the former read-only wrapper setValue behavior behind MODE=e2e at fixed 900x720, 13px, 1x zoom, 2,775,880px content height, and five-second cadence. The control retains a flat incoming value like the former controlled IPC prop. Each legacy arm must disrupt selection, Find active-match state, visible range, or scroll anchor, then restore identical model length/tail/geometry and anchor state before the fixed watcher arm; every fixed arm must preserve them. Production builds never install the control. The original exact-file dev repro also recorded renderer exit code 5." }, + "performanceBudget": { "required": true, "evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node command requires --expose-gc and --pool=threads so worker GC is available; alternating fresh real-Monaco arms force GC and deterministic event-loop settlement after every operation. Observed p95 ranges: 9 MiB append 4.02-5.51ms versus replacement 81.42-83.76ms; 50 MiB append 22.72-26.60ms versus replacement 445.11-449.21ms. Electron alternates each legacy replacement control and fixed watcher suffix from equivalent restored model state/geometry, samples forced-GC JS heap plus app.getAppMetrics renderer working set and OS private memory before/after/settled, asserts retained memory within max(20MiB,10%), and requires suffix peak deltas for jsHeapMb, workingSetMb, and privateMb not exceed the paired legacy controls." }, + "promotionCriteria": [ + "Collect stable soak history on macOS, Linux, and Windows.", + "Accumulate 100 consecutive deterministic gate passes or 14 days without unexplained flakes." + ], + "knownGaps": [ + "No live Windows or Linux View Log evidence yet." + ], + "demotionRule": "Quarantine the Electron scenario if it flakes without a product or harness bug; demote if viewport/Find drift, renderer loss, p95 regression, or memory retention exceeds the registered budgets." + }, { "id": "terminal-session.snapshot-freshness", "title": "Stale liveness snapshots cannot close newer PTY bindings", diff --git a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx new file mode 100644 index 000000000..77c78d0a4 --- /dev/null +++ b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' + +const lifecycle = vi.hoisted(() => ({ + events: [] as string[], + models: new Map() +})) + +vi.mock('@/lib/lazy-with-retry', async () => { + const React = await import('react') + const { syncContentOnMount } = await import('./monaco-content-sync') + return { + lazyWithRetry: (factory: () => Promise) => { + if (!factory.toString().includes('/MonacoEditor.tsx')) { + return () => null + } + return function MockRetainedMonaco(props: { filePath: string; content: string }) { + /* oxlint-disable react-hooks/exhaustive-deps -- Mount-only by design: a prop-effect would hide a missing outer React remount. */ + React.useEffect(() => { + lifecycle.events.push(`mount:${props.filePath}`) + const retained = lifecycle.models.get(props.filePath) ?? { content: '', undo: [] } + const model = { + getValue: () => retained.content, + getEOL: () => '\n', + getFullModelRange: () => ({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: retained.content.length + 1 + }), + pushEditOperations: (_selections: unknown[], operations: { text: string }[]) => { + retained.content = operations[0]?.text ?? retained.content + } + } + // Why: exercising the real mount reconciler makes outer key ordering + // observable without replacing Monaco's retained-model semantics. + syncContentOnMount( + { + getModel: () => model, + pushUndoStop: () => { + retained.undo.push('unexpected undo stop') + return true + } + } as never, + props.content + ) + lifecycle.models.set(props.filePath, retained) + return () => { + lifecycle.events.push(`unmount:${props.filePath}`) + } + }, []) + /* oxlint-enable react-hooks/exhaustive-deps */ + return null + } + } + } +}) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: Record) => unknown) => + selector({ + worktreesByRepo: {}, + openFile: vi.fn(), + openMarkdownPreview: vi.fn(), + openConflictReviewFile: vi.fn(), + openConflictReview: vi.fn(), + closeFile: vi.fn(), + setRightSidebarTab: vi.fn(), + setPendingEditorReveal: vi.fn(), + reloadOpenCheckRunDetailsTab: vi.fn() + }), + { + getState: () => ({ + folderWorkspaces: [], + projectGroups: [], + repos: [], + settings: {}, + worktreesByRepo: {}, + openFile: vi.fn(), + openMarkdownPreview: vi.fn(), + openConflictReviewFile: vi.fn(), + openConflictReview: vi.fn(), + closeFile: vi.fn(), + setRightSidebarTab: vi.fn(), + setPendingEditorReveal: vi.fn(), + reloadOpenCheckRunDetailsTab: vi.fn() + }) + } + ) +})) + +import { EditorContent } from './EditorContent' + +function file(filePath: string): OpenFile { + return { + id: filePath, + filePath, + relativePath: filePath.split('/').at(-1) ?? filePath, + worktreeId: 'repo::/repo', + language: 'typescript', + isDirty: false, + mode: 'edit' + } +} + +function props(activeFile: OpenFile, content: string) { + return { + activeFile, + viewStateScopeId: 'same-pane', + fileContents: { [activeFile.id]: { content, isBinary: false } }, + diffContents: {}, + editBuffers: {}, + openFiles: [activeFile], + worktreeEntries: [], + resolvedLanguage: 'typescript', + isMarkdown: false, + isMermaid: false, + isCsv: false, + isNotebook: false, + mdViewMode: 'rich' as const, + isChangesMode: false, + sideBySide: false, + pendingEditorReveal: null, + handleContentChange: vi.fn(), + handleContentChangeForFile: vi.fn(), + handleDirtyStateHint: vi.fn(), + handleSave: vi.fn(), + handleSaveForFile: vi.fn(), + reloadContent: vi.fn() + } +} + +afterEach(() => { + cleanup() + lifecycle.events.length = 0 + lifecycle.models.clear() +}) + +describe('EditorContent Monaco lifecycle boundary', () => { + it('unmounts the prior path before reconciling a stale retained target model', () => { + const first = file('/repo/first.ts') + const second = file('/repo/second.ts') + lifecycle.models.set(first.filePath, { content: 'first with edits', undo: ['first undo'] }) + lifecycle.models.set(second.filePath, { content: 'stale second', undo: ['second undo'] }) + const view = render() + + view.rerender() + + expect(lifecycle.events).toEqual([ + 'mount:/repo/first.ts', + 'unmount:/repo/first.ts', + 'mount:/repo/second.ts' + ]) + expect(lifecycle.models.get(second.filePath)).toEqual({ + content: 'fresh second', + undo: ['second undo'] + }) + expect(lifecycle.models.get(first.filePath)).toEqual({ + content: 'first with edits', + undo: ['first undo'] + }) + }) +}) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index abab01bf1..3e56b7c58 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -313,10 +313,10 @@ export function EditorContent({ // Why: Without a key, React reuses the same MonacoEditor instance when // switching tabs or split panes, just updating props. That means // useLayoutEffect cleanup (which snapshots scroll position) never fires. - // Keying on the visible pane identity forces unmount/remount so each split - // tab keeps its own viewport state even when the underlying file is shared. + // Keying on the visible pane and path forces remount before a retained target + // model mounts, so the old path cannot receive the new file's reconciliation. ({ current: null as Record | null })) + +vi.mock('@monaco-editor/react', () => ({ + default: (props: Record) => { + editorProps.current = props + return null + }, + loader: { config: vi.fn() } +})) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + settings: { theme: 'dark', terminalFontSize: 13, terminalFontFamily: 'monospace' }, + editorFontZoomLevel: 0, + setPendingEditorReveal: vi.fn(), + setEditorCursorLine: vi.fn(), + addDiffComment: vi.fn(), + deleteDiffComment: vi.fn(), + updateDiffComment: vi.fn(), + scrollToDiffCommentId: null, + setScrollToDiffCommentId: vi.fn(), + worktreeDiffComments: {} + }) +})) +vi.mock('../diff-comments/useDiffCommentDecorator', () => ({ + useDiffCommentDecorator: vi.fn() +})) +vi.mock('./useContextualCopySetup', () => ({ + useContextualCopySetup: () => ({ setupCopy: vi.fn(), toastNode: null }) +})) + +import MonacoEditor from './MonacoEditor' + +afterEach(() => { + cleanup() + editorProps.current = null +}) + +describe('MonacoEditor content ownership', () => { + it('initializes the wrapper without a controlled value updater', () => { + render( + + ) + + expect(editorProps.current?.defaultValue).toBe('initial content') + expect(editorProps.current).not.toHaveProperty('value') + }) +}) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index d3cfc60fc..d8f92a00c 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -58,6 +58,7 @@ import { getMonacoAutoHeightForContent, isMonacoAutoHeightCapped } from './monaco-auto-height' +import { installMonacoE2EProbe } from './monaco-e2e-probe' type MonacoEditorProps = { fileId: string @@ -332,6 +333,7 @@ export default function MonacoEditor({ (editorInstance, monaco) => { editorRef.current = editorInstance setMountedEditor(editorInstance) + const uninstallE2EProbe = installMonacoE2EProbe(editorInstance, filePath) let autoHeightSub: { dispose: () => void } | null = null let autoHeightFrame: number | null = null const updateAutoHeight = (): void => { @@ -507,6 +509,7 @@ export default function MonacoEditor({ } conflictDecorationsRef.current?.clear() conflictDecorationsRef.current = null + uninstallE2EProbe() editorRef.current = null setMountedEditor(null) setCommentPopover(null) @@ -826,7 +829,9 @@ export default function MonacoEditor({ left - right) + return sorted[Math.ceil(sorted.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY +} + +async function settleBetweenArms(): Promise { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('Forced GC unavailable; run the benchmark with node --expose-gc') + } + gc() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function measureReconciliation( + initial: string, + appended: string, + rewritten: string +): Promise<{ appendSamples: number[]; replacementSamples: number[] }> { + const appendSamples: number[] = [] + const replacementSamples: number[] = [] + const measure = (next: string): number => { + const model = monaco.editor.createModel(initial, 'plaintext') + const editorInstance = { + getModel: () => model, + pushUndoStop: () => true + } as unknown as monaco.editor.IStandaloneCodeEditor + const startedAt = performance.now() + syncContentUpdate(editorInstance, next) + const duration = performance.now() - startedAt + model.dispose() + return duration + } + for (let index = 0; index < WARMUP_COUNT + SAMPLE_COUNT; index++) { + const appendDuration = measure(appended) + await settleBetweenArms() + const replacementDuration = measure(rewritten) + await settleBetweenArms() + if (index >= WARMUP_COUNT) { + appendSamples.push(appendDuration) + replacementSamples.push(replacementDuration) + } + } + return { appendSamples, replacementSamples } +} + +async function runReconciliationBenchmark( + label: string, + bytes: number, + maxAppendP95Ms: number +): Promise { + const base = `header\n${'x'.repeat(bytes - 8)}` + const appended = `${base}\n{"type":"tail"}` + const rewritten = `H${base.slice(1)}` + const { appendSamples, replacementSamples } = await measureReconciliation( + base, + appended, + rewritten + ) + const appendP95 = percentile95(appendSamples) + const replacementP95 = percentile95(replacementSamples) + console.log(`[monaco-content-sync] ${label} ${JSON.stringify({ appendP95, replacementP95 })}`) + expect(appendP95).toBeLessThan(maxAppendP95Ms) + expect(replacementP95 / appendP95).toBeGreaterThanOrEqual(2) +} + +bench( + '9 MiB append and replacement p95', + () => runReconciliationBenchmark('9 MiB model', 9 * 1024 * 1024, 50), + { iterations: 1, time: 1, warmupIterations: 0, warmupTime: 0 } +) + +bench( + '50 MiB append and replacement p95', + () => runReconciliationBenchmark('50 MiB model', 50 * 1024 * 1024, 100), + { iterations: 1, time: 1, warmupIterations: 0, warmupTime: 0 } +) diff --git a/src/renderer/src/components/editor/monaco-content-sync.test.ts b/src/renderer/src/components/editor/monaco-content-sync.test.ts new file mode 100644 index 000000000..ccf958f12 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-content-sync.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest' +import type { editor } from 'monaco-editor' +import { syncContentOnMount, syncContentUpdate } from './monaco-content-sync' + +function createHarness( + initialContent: string, + eol = '\n' +): { + editorInstance: editor.IStandaloneCodeEditor + getValue: ReturnType + getFullModelRange: ReturnType + pushEditOperations: ReturnType + pushUndoStop: ReturnType +} { + const getValue = vi.fn(() => initialContent) + const getFullModelRange = vi.fn(() => ({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: 3, + endColumn: 5 + })) + const pushEditOperations = vi.fn() + const model = { + getValue, + getEOL: () => eol, + getFullModelRange, + pushEditOperations + } as unknown as editor.ITextModel + const pushUndoStop = vi.fn() + const editorInstance = { + getModel: () => model, + pushUndoStop + } as unknown as editor.IStandaloneCodeEditor + return { editorInstance, getValue, getFullModelRange, pushEditOperations, pushUndoStop } +} + +describe('monaco content sync', () => { + it('inserts only an append-only suffix at the model end', () => { + const harness = createHarness('first\nsecond\nlast') + + syncContentUpdate(harness.editorInstance, 'first\nsecond\nlast\nnext') + + expect(harness.getValue).toHaveBeenCalledTimes(1) + expect(harness.getFullModelRange).toHaveBeenCalledTimes(1) + expect(harness.pushEditOperations).toHaveBeenCalledWith( + [], + [ + { + range: { + startLineNumber: 3, + startColumn: 5, + endLineNumber: 3, + endColumn: 5 + }, + text: '\nnext' + } + ], + expect.any(Function) + ) + expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) + }) + + it('does nothing for identical content', () => { + const harness = createHarness('unchanged') + + syncContentUpdate(harness.editorInstance, 'unchanged') + + expect(harness.getValue).toHaveBeenCalledTimes(1) + expect(harness.getFullModelRange).not.toHaveBeenCalled() + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + + it.each([ + [ + 'CRLF file content into an LF model', + 'first\nsecond\nlast', + '\n', + 'first\r\nsecond\r\nlast\r\nnext', + '\nnext' + ], + [ + 'LF file content into a CRLF model', + 'first\r\nsecond\r\nlast', + '\r\n', + 'first\nsecond\nlast\nnext', + '\r\nnext' + ], + [ + 'mixed file content into an LF model', + 'first\nsecond\nlast', + '\n', + 'first\r\nsecond\rlast\nnext', + '\nnext' + ] + ])( + 'keeps %s on the suffix path', + (_label, initialContent, modelEol, nextContent, expectedSuffix) => { + const harness = createHarness(initialContent, modelEol) + + syncContentUpdate(harness.editorInstance, nextContent) + + expect(harness.pushEditOperations).toHaveBeenCalledWith( + [], + [expect.objectContaining({ text: expectedSuffix })], + expect.any(Function) + ) + expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) + } + ) + + it.each([ + ['same-length rewrite', 'before', 'after!'], + ['prefix mismatch', 'before', 'changed content'], + ['truncation', 'longer content', 'short'] + ])('fully replaces a %s', (_label, initialContent, nextContent) => { + const harness = createHarness(initialContent) + + syncContentUpdate(harness.editorInstance, nextContent) + + expect(harness.getValue).toHaveBeenCalledTimes(1) + expect(harness.pushEditOperations).toHaveBeenCalledWith( + [], + [{ range: harness.getFullModelRange.mock.results[0]?.value, text: nextContent }], + expect.any(Function) + ) + expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) + }) + + it('reconciles a stale retained model on mount without undo stops', () => { + const harness = createHarness('stale') + + expect(syncContentOnMount(harness.editorInstance, 'fresh')).toBe(true) + + expect(harness.pushEditOperations).toHaveBeenCalledTimes(1) + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + + it('does nothing on mount when content already matches', () => { + const harness = createHarness('same') + + expect(syncContentOnMount(harness.editorInstance, 'same')).toBe(false) + + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + + it('does nothing on mount when only raw file EOLs differ from the model', () => { + const harness = createHarness('first\nsecond') + + expect(syncContentOnMount(harness.editorInstance, 'first\r\nsecond')).toBe(false) + + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + + it('reconciles only the retained target model after a path switch', () => { + const priorPath = createHarness('prior path with user edits') + const targetPath = createHarness('stale target') + + syncContentOnMount(targetPath.editorInstance, 'fresh target') + + expect(targetPath.pushEditOperations).toHaveBeenCalledTimes(1) + expect(targetPath.pushUndoStop).not.toHaveBeenCalled() + expect(priorPath.pushEditOperations).not.toHaveBeenCalled() + expect(priorPath.pushUndoStop).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/monaco-content-sync.ts b/src/renderer/src/components/editor/monaco-content-sync.ts index 64b9ec662..b7894af43 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.ts @@ -1,5 +1,15 @@ import type { editor } from 'monaco-editor' +function normalizeToModelEol(content: string, model: editor.ITextModel): string { + const eol = model.getEOL() + // Why: Monaco normalizes model line endings, while filesystem content keeps + // its raw EOLs. Compare the representation Monaco can actually retain. + if (eol === '\n' && !content.includes('\r')) { + return content + } + return content.replace(/\r\n|\r|\n/g, eol) +} + /** * Overwrite a Monaco model's text via pushEditOperations so the change * participates in the undo stack (unlike setValue, which blows it away). @@ -7,10 +17,11 @@ import type { editor } from 'monaco-editor' function replaceModelContent( editorInstance: editor.IStandaloneCodeEditor, model: editor.ITextModel, + currentContent: string, content: string, withUndoStops: boolean ): void { - if (model.getValue() === content) { + if (currentContent === content) { return } const fullRange = model.getFullModelRange() @@ -41,13 +52,15 @@ export function syncContentOnMount( if (!model) { return false } - if (model.getValue() === content) { + const currentContent = model.getValue() + const normalizedContent = normalizeToModelEol(content, model) + if (currentContent === normalizedContent) { return false } // Why: no undo stop on mount — the retained model's text was already the // user's last-known state, and adding an undo entry here would make Cmd+Z // revert to the pre-remount text, which is confusing. - replaceModelContent(editorInstance, model, content, false) + replaceModelContent(editorInstance, model, currentContent, normalizedContent, false) return true } @@ -67,5 +80,37 @@ export function syncContentUpdate( if (!model) { return } - replaceModelContent(editorInstance, model, content, true) + const currentContent = model.getValue() + const normalizedContent = normalizeToModelEol(content, model) + if (currentContent.length === normalizedContent.length) { + replaceModelContent(editorInstance, model, currentContent, normalizedContent, true) + return + } + if ( + normalizedContent.length > currentContent.length && + normalizedContent.startsWith(currentContent) + ) { + // Why: preserving the existing prefix lets Monaco retain viewport, + // selection, find-widget, and tokenization state above a live-file append. + const fullRange = model.getFullModelRange() + editorInstance.pushUndoStop() + model.pushEditOperations( + [], + [ + { + range: { + startLineNumber: fullRange.endLineNumber, + startColumn: fullRange.endColumn, + endLineNumber: fullRange.endLineNumber, + endColumn: fullRange.endColumn + }, + text: normalizedContent.slice(currentContent.length) + } + ], + () => null + ) + editorInstance.pushUndoStop() + return + } + replaceModelContent(editorInstance, model, currentContent, normalizedContent, true) } diff --git a/src/renderer/src/components/editor/monaco-e2e-probe.ts b/src/renderer/src/components/editor/monaco-e2e-probe.ts new file mode 100644 index 000000000..f6284a517 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-e2e-probe.ts @@ -0,0 +1,99 @@ +import type { editor, IRange, ISelection } from 'monaco-editor' + +export type MonacoE2ESnapshot = { + contentHeight: number + scrollHeight: number + scrollTop: number + visibleRanges: IRange[] + selection: ISelection | null + valueLength: number + valueTail: string + find: { + open: boolean + query: string + activeMatch: string + } +} + +export type MonacoE2EProbe = { + filePath: string + restoreLegacySetValueControl: () => void + restoreScrollTop: (scrollTop: number) => void + runLegacySetValueAppend: (suffix: string) => void + snapshot: () => MonacoE2ESnapshot +} + +export function installMonacoE2EProbe( + editorInstance: editor.IStandaloneCodeEditor, + filePath: string +): () => void { + if (import.meta.env.MODE !== 'e2e') { + return () => {} + } + let legacyControlOriginalValue: string | null = null + let legacyControlIncomingValue: string | null = null + const probe: MonacoE2EProbe = { + filePath, + runLegacySetValueAppend: (suffix: string): void => { + if (legacyControlOriginalValue !== null) { + throw new Error('Legacy control must be restored before running again') + } + legacyControlOriginalValue = editorInstance.getValue() + // Why: the former controlled wrapper retained a flat IPC-delivered prop + // while setValue rebuilt the model; preserve that ownership in the control. + legacyControlIncomingValue = new TextDecoder().decode( + new TextEncoder().encode(`${legacyControlOriginalValue}${suffix}`) + ) + // Why: reproduces the former controlled read-only wrapper's unconditional + // whole-model setValue without exposing a switch in production builds. + editorInstance.setValue(legacyControlIncomingValue) + }, + restoreLegacySetValueControl: (): void => { + if (legacyControlOriginalValue === null) { + return + } + editorInstance.setValue(legacyControlOriginalValue) + legacyControlOriginalValue = null + legacyControlIncomingValue = null + }, + restoreScrollTop: (scrollTop: number): void => { + // Why: the legacy setValue control can perturb Monaco's pixel rounding; + // paired fixed-path measurements must start from the recorded geometry. + editorInstance.setScrollTop(scrollTop) + }, + snapshot: (): MonacoE2ESnapshot => { + const container = editorInstance.getContainerDomNode() + const findWidget = container.querySelector('.find-widget') + const findInput = findWidget?.querySelector('.input') + const model = editorInstance.getModel() + const valueLength = model?.getValueLength() ?? 0 + const lastLineNumber = model?.getLineCount() ?? 1 + const lastLine = model?.getLineContent(lastLineNumber) ?? '' + const lastNonEmptyLine = + lastLine || lastLineNumber === 1 + ? lastLine + : (model?.getLineContent(lastLineNumber - 1) ?? '') + return { + contentHeight: editorInstance.getContentHeight(), + scrollHeight: editorInstance.getScrollHeight(), + scrollTop: editorInstance.getScrollTop(), + visibleRanges: [...editorInstance.getVisibleRanges()], + selection: editorInstance.getSelection(), + valueLength, + valueTail: lastNonEmptyLine.slice(-256), + find: { + open: findWidget?.getAttribute('aria-hidden') !== 'true', + query: findInput?.value ?? '', + activeMatch: + findWidget?.querySelector('.matchesCount')?.textContent?.trim() ?? '' + } + } + } + } + window.__monacoEditorE2E = probe + return () => { + if (window.__monacoEditorE2E === probe) { + delete window.__monacoEditorE2E + } + } +} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index cbf24b7c1..676fa2535 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -3,6 +3,7 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { OnboardingFeatureSetupDeps } from '@/components/onboarding/onboarding-feature-setup' import type { languages } from 'monaco-editor' +import type { MonacoE2EProbe } from './components/editor/monaco-e2e-probe' declare module 'monaco-editor/esm/vs/basic-languages/python/python.js' { export const conf: languages.LanguageConfiguration @@ -71,6 +72,7 @@ declare global { parkDelayMs: number parkedTabIds: () => string[] } + __monacoEditorE2E?: MonacoE2EProbe } } diff --git a/tests/e2e/agent-session-log-tail-stability.spec.ts b/tests/e2e/agent-session-log-tail-stability.spec.ts new file mode 100644 index 000000000..243403e79 --- /dev/null +++ b/tests/e2e/agent-session-log-tail-stability.spec.ts @@ -0,0 +1,394 @@ +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' + +const ANCHOR_TOKEN = 'E2E_LIVE_LOG_STABLE_ANCHOR' +const INITIAL_PAYLOAD_BYTES = 9 * 1024 * 1024 +const APPEND_CADENCE_MS = 5_000 +const SETTLEMENT_MS = 500 + +type CrashProbe = { processGone: { reason: string; exitCode: number } | null } +type MemorySample = { + jsHeapMb: number + privateMb: number + workingSetMb: number +} + +declare global { + // eslint-disable-next-line no-var -- main-process global probe for this spec + var __liveLogCrashProbe: CrashProbe | undefined +} + +test.describe('Agent Session History live log', () => { + test.use({ + orcaAppExtraArgs: ['--js-flags=--expose-gc', '--enable-precise-memory-info'] + }) + + test('keeps a long View Log viewport stable while the transcript grows', async ({ + electronApp, + orcaPage, + testRepoPath + }, testInfo) => { + test.setTimeout(10 * 60_000) + const fixture = await seedSyntheticSession(electronApp, testRepoPath) + await armMainProcessCrashProbe(electronApp) + await electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.webContents.setZoomFactor(1) + }) + + await orcaPage.setViewportSize({ width: 520, height: 720 }) + await openSessionHistory(orcaPage) + const title = orcaPage.getByText(fixture.title, { exact: true }).first() + await expect(title).toBeVisible({ timeout: 30_000 }) + await title.click() + await orcaPage.getByText('View Log', { exact: true }).click() + await expect + .poll(() => readProbeOrNull(orcaPage), { timeout: 120_000 }) + .toMatchObject({ filePath: fixture.filePath, valueLength: fixture.initialLength }) + await orcaPage.setViewportSize({ width: 900, height: 720 }) + await orcaPage.evaluate(async () => { + const state = window.__store?.getState() + state?.setRightSidebarOpen(false) + state?.setEditorFontZoomLevel(0) + await state?.updateSettings({ terminalFontSize: 13 }) + }) + + let baseline = await restoreAnchorState(orcaPage, true) + console.log(`[live-log-stability] geometry ${JSON.stringify(baseline)}`) + // The legacy whole-model baseline at this fixed viewport/font/zoom moved + // the anchor at 2,775,880px; missing that geometry cannot claim containment. + expect(baseline.contentHeight).toBeGreaterThanOrEqual(2_775_880) + expect(baseline.visibleRanges.length).toBeGreaterThan(0) + expect(baseline.find).toMatchObject({ open: true, query: ANCHOR_TOKEN }) + expect(baseline.find.activeMatch).not.toBe('') + + const suffixMemory: { before: MemorySample; after: MemorySample; settled: MemorySample }[] = [] + const replacementMemory: { + before: MemorySample + after: MemorySample + settled: MemorySample + }[] = [] + + for (let batch = 0; batch < 3; batch++) { + const equivalentState = baseline + replacementMemory.push(await measureLegacyControl(electronApp, orcaPage, baseline, batch)) + baseline = await restoreAnchorState(orcaPage, baseline.find.open, equivalentState.scrollTop) + expect(baseline.contentHeight).toBe(equivalentState.contentHeight) + expect(baseline.valueLength).toBe(equivalentState.valueLength) + expect(baseline.valueTail).toBe(equivalentState.valueTail) + expect(baseline.visibleRanges).toEqual(equivalentState.visibleRanges) + expect(baseline.selection).toEqual(equivalentState.selection) + expect(baseline.find).toEqual(equivalentState.find) + expect(Math.abs(baseline.scrollTop - equivalentState.scrollTop)).toBeLessThanOrEqual(2) + const suffix = `${JSON.stringify({ type: 'e2e_append', batch, text: 'tail-only' })}\n` + await forceGcAndSettle(orcaPage) + const before = await readMemory(electronApp, orcaPage) + appendFileSync(fixture.filePath, suffix) + fixture.initialLength += suffix.length + await orcaPage.waitForTimeout(APPEND_CADENCE_MS) + await expect + .poll(() => readProbe(orcaPage), { timeout: 30_000 }) + .toMatchObject({ valueLength: fixture.initialLength }) + const after = await readMemory(electronApp, orcaPage) + await forceGcAndSettle(orcaPage) + const settled = await readMemory(electronApp, orcaPage) + suffixMemory.push({ before, after, settled }) + const current = await readProbe(orcaPage) + console.log(`[live-log-stability] anchor ${JSON.stringify({ batch, baseline, current })}`) + expect(current.visibleRanges).toEqual(baseline.visibleRanges) + expect(current.valueTail).toBe(suffix.trimEnd()) + expect(current.selection).toEqual(baseline.selection) + expect(current.find).toEqual(baseline.find) + expect(Math.abs(current.scrollTop - baseline.scrollTop)).toBeLessThanOrEqual(2) + expect(await orcaPage.evaluate(() => 2 + 2)).toBe(4) + expect((await readMainProcessCrashProbe(electronApp)).processGone).toBeNull() + baseline = current + if (batch === 0) { + await orcaPage.keyboard.press('Escape') + await expect(orcaPage.locator('.monaco-editor .find-widget')).toHaveAttribute( + 'aria-hidden', + 'true' + ) + baseline = await readProbe(orcaPage) + expect(baseline.find.open).toBe(false) + } + } + + console.log( + `[live-log-stability] memory ${JSON.stringify({ suffixMemory, replacementMemory })}` + ) + assertMemoryBudget(suffixMemory, replacementMemory) + await testInfo.attach('synthetic-live-log-stable', { + body: await orcaPage.screenshot(), + contentType: 'image/png' + }) + }) +}) + +async function seedSyntheticSession( + electronApp: ElectronApplication, + cwd: string +): Promise<{ filePath: string; initialLength: number; title: string }> { + const userData = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const title = `Synthetic live log ${Date.now()}` + const sessionId = `e2e-live-log-${Date.now()}` + const sessionsDir = path.join( + userData, + 'codex-runtime-home', + 'home', + 'sessions', + '2026', + '07', + '12' + ) + mkdirSync(sessionsDir, { recursive: true }) + const filePath = path.join(sessionsDir, `rollout-${sessionId}.jsonl`) + const records = [ + JSON.stringify({ + timestamp: '2026-07-12T12:00:00.000Z', + type: 'session_meta', + payload: { id: sessionId, cwd } + }), + JSON.stringify({ + timestamp: '2026-07-12T12:00:01.000Z', + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'text', text: title }] } + }), + JSON.stringify({ type: 'synthetic', text: 'x'.repeat(INITIAL_PAYLOAD_BYTES / 2) }), + JSON.stringify({ type: 'anchor', text: ANCHOR_TOKEN }), + JSON.stringify({ type: 'synthetic', text: 'y'.repeat(INITIAL_PAYLOAD_BYTES / 2) }) + ] + const content = `${records.join('\n')}\n` + writeFileSync(filePath, content) + return { filePath, initialLength: content.length, title } +} + +async function openSessionHistory(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setRightSidebarOpen(true) + store.getState().setRightSidebarTab('vault') + }) + await page.evaluate(async () => window.api.aiVault.listSessions({ force: true })) + await page.getByRole('button', { name: 'Refresh Session History' }).click() +} + +async function focusAnchorWithFind(page: Page): Promise { + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control' + await page.locator('.monaco-editor').click() + await page.keyboard.press(`${modifier}+f`) + const findInput = page.locator('.monaco-editor .find-widget .input').first() + await expect(findInput).toBeVisible() + await findInput.fill(ANCHOR_TOKEN) + await page.keyboard.press('Enter') +} + +async function restoreAnchorState( + page: Page, + findOpen: boolean, + targetScrollTop?: number +): Promise>> { + await focusAnchorWithFind(page) + await expect + .poll( + async () => { + const snapshot = await readProbe(page) + return (snapshot.selection as { startLineNumber?: number } | null)?.startLineNumber + }, + { timeout: 60_000 } + ) + .toBe(4) + await expect + .poll(async () => { + const range = (await readProbe(page)).visibleRanges[0] as { + startLineNumber?: number + endLineNumber?: number + } + return (range.startLineNumber ?? 0) <= 4 && (range.endLineNumber ?? 0) >= 4 + }) + .toBe(true) + if (!findOpen) { + await page.keyboard.press('Escape') + await expect(page.locator('.monaco-editor .find-widget')).toHaveAttribute('aria-hidden', 'true') + } + if (targetScrollTop !== undefined) { + await page.evaluate((scrollTop) => { + window.__monacoEditorE2E?.restoreScrollTop(scrollTop) + }, targetScrollTop) + await expect.poll(async () => (await readProbe(page)).scrollTop).toBe(targetScrollTop) + } + return readProbe(page) +} + +async function readProbe(page: Page): Promise<{ + contentHeight: number + filePath: string + scrollTop: number + selection: unknown + valueLength: number + valueTail: string + visibleRanges: unknown[] + find: { open: boolean; query: string; activeMatch: string } +}> { + return page.evaluate(() => { + const probe = window.__monacoEditorE2E + if (!probe) { + throw new Error('Monaco E2E probe unavailable') + } + return { filePath: probe.filePath, ...probe.snapshot() } + }) +} + +async function armMainProcessCrashProbe(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({ BrowserWindow }) => { + const probe: CrashProbe = { processGone: null } + globalThis.__liveLogCrashProbe = probe + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.on('render-process-gone', (_event, details) => { + probe.processGone = { reason: details.reason, exitCode: details.exitCode ?? -1 } + }) + } + }) +} + +async function readMainProcessCrashProbe(electronApp: ElectronApplication): Promise { + return electronApp.evaluate(() => globalThis.__liveLogCrashProbe ?? { processGone: null }) +} + +async function forceGcAndSettle(page: Page): Promise { + await page.evaluate(() => { + const gc = (window as unknown as { gc?: () => void }).gc + if (!gc) { + throw new Error('Forced GC unavailable; launch must include --js-flags=--expose-gc') + } + gc() + gc() + }) + await page.waitForTimeout(SETTLEMENT_MS) +} + +async function readMemory(electronApp: ElectronApplication, page: Page): Promise { + const jsHeapMb = await page.evaluate(() => { + const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory + ?.usedJSHeapSize + if (!memory) { + throw new Error('Precise renderer JS heap metrics unavailable') + } + return memory / 1024 / 1024 + }) + const metric = await electronApp.evaluate(({ app, BrowserWindow }) => { + const rendererPid = BrowserWindow.getAllWindows()[0]?.webContents.getOSProcessId() + const rendererMetric = app.getAppMetrics().find((candidate) => candidate.pid === rendererPid) + if (!rendererMetric) { + throw new Error(`Renderer app metric unavailable for pid ${rendererPid ?? 'unknown'}`) + } + return { + pid: rendererMetric.pid, + privateMb: rendererMetric.memory.privateBytes + ? rendererMetric.memory.privateBytes / 1024 + : null, + workingSetMb: rendererMetric.memory.workingSetSize / 1024 + } + }) + const privateMb = metric.privateMb ?? readPrivateMemoryMb(metric.pid) + return { jsHeapMb, privateMb, workingSetMb: metric.workingSetMb } +} + +function readPrivateMemoryMb(pid: number): number { + if (process.platform === 'linux') { + const status = readFileSync(`/proc/${pid}/status`, 'utf8') + const privateKb = /RssAnon:\s+(\d+) kB/.exec(status)?.[1] + if (!privateKb) { + throw new Error(`Unable to parse renderer RssAnon for pid ${pid}`) + } + return Number(privateKb) / 1024 + } + if (process.platform !== 'darwin') { + throw new Error('app.getAppMetrics privateBytes unavailable on Windows') + } + // Electron omits MemoryInfo.privateBytes on macOS; footprint's + // phys_footprint is the OS private-memory equivalent for the same renderer PID. + const output = execFileSync('footprint', ['-p', String(pid), '-f', 'bytes', '--noCategories'], { + encoding: 'utf8' + }) + const bytes = /phys_footprint:\s+(\d+) B/.exec(output)?.[1] + if (!bytes) { + throw new Error(`Unable to parse renderer private footprint for pid ${pid}`) + } + return Number(bytes) / 1024 / 1024 +} + +async function measureLegacyControl( + electronApp: ElectronApplication, + page: Page, + baseline: Awaited>, + batch: number +): Promise<{ before: MemorySample; after: MemorySample; settled: MemorySample }> { + await forceGcAndSettle(page) + const before = await readMemory(electronApp, page) + await page.evaluate((index) => { + window.__monacoEditorE2E?.runLegacySetValueAppend( + `${JSON.stringify({ type: 'legacy_set_value_control', index, text: 'tail-only' })}\n` + ) + }, batch) + await page.waitForTimeout(APPEND_CADENCE_MS) + const after = await readMemory(electronApp, page) + const disrupted = await readProbe(page) + console.log( + `[live-log-stability] legacy-control ${JSON.stringify({ batch, baseline, disrupted })}` + ) + expect(hasInstabilitySignal(baseline, disrupted)).toBe(true) + await forceGcAndSettle(page) + const settled = await readMemory(electronApp, page) + await page.evaluate(() => window.__monacoEditorE2E?.restoreLegacySetValueControl()) + await expect.poll(() => readProbe(page)).toMatchObject({ valueLength: baseline.valueLength }) + expect((await readMainProcessCrashProbe(electronApp)).processGone).toBeNull() + return { before, after, settled } +} + +function hasInstabilitySignal( + baseline: Awaited>, + current: Awaited> +): boolean { + return ( + JSON.stringify(current.selection) !== JSON.stringify(baseline.selection) || + JSON.stringify(current.find) !== JSON.stringify(baseline.find) || + JSON.stringify(current.visibleRanges) !== JSON.stringify(baseline.visibleRanges) || + Math.abs(current.scrollTop - baseline.scrollTop) > 2 + ) +} + +function assertMemoryBudget( + suffix: { before: MemorySample; after: MemorySample; settled: MemorySample }[], + replacement: { before: MemorySample; after: MemorySample; settled: MemorySample }[] +): void { + expect(suffix).toHaveLength(replacement.length) + expect(suffix.length).toBeGreaterThan(0) + for (const sample of suffix) { + for (const field of ['jsHeapMb', 'workingSetMb', 'privateMb'] as const) { + const retainedAllowance = Math.max(20, sample.before[field] * 0.1) + expect(sample.settled[field] - sample.before[field]).toBeLessThanOrEqual(retainedAllowance) + } + } + for (const [index, sample] of suffix.entries()) { + const pairedReplacement = replacement[index] + expect(pairedReplacement).toBeDefined() + for (const field of ['jsHeapMb', 'workingSetMb', 'privateMb'] as const) { + const suffixPeak = sample.after[field] - sample.before[field] + const replacementPeak = pairedReplacement.after[field] - pairedReplacement.before[field] + expect(suffixPeak).toBeLessThanOrEqual(replacementPeak) + } + } +} + +async function readProbeOrNull(page: Page): Promise> | null> { + return page.evaluate(() => { + const probe = window.__monacoEditorE2E + return probe ? { filePath: probe.filePath, ...probe.snapshot() } : null + }) +}