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 <brennanbenson@Brennans-MacBook-Pro.local>
This commit is contained in:
parent
67cd462b24
commit
fcd60a03f8
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, { content: string; undo: string[] }>()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/lazy-with-retry', async () => {
|
||||
const React = await import('react')
|
||||
const { syncContentOnMount } = await import('./monaco-content-sync')
|
||||
return {
|
||||
lazyWithRetry: (factory: () => Promise<unknown>) => {
|
||||
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<string, unknown>) => 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(<EditorContent {...props(first, 'first with edits')} />)
|
||||
|
||||
view.rerender(<EditorContent {...props(second, 'fresh second')} />)
|
||||
|
||||
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']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
<MonacoEditor
|
||||
key={viewStateScopeId}
|
||||
key={`${viewStateScopeId}\u0000${activeFile.filePath}`}
|
||||
fileId={activeFile.id}
|
||||
filePath={activeFile.filePath}
|
||||
viewStateKey={editorViewStateKey}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const editorProps = vi.hoisted(() => ({ current: null as Record<string, unknown> | null }))
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
editorProps.current = props
|
||||
return null
|
||||
},
|
||||
loader: { config: vi.fn() }
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => 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(
|
||||
<MonacoEditor
|
||||
fileId="file"
|
||||
filePath="/repo/file.jsonl"
|
||||
viewStateKey="pane:file"
|
||||
relativePath="file.jsonl"
|
||||
content="initial content"
|
||||
language="jsonl"
|
||||
onContentChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
|
||||
expect(editorProps.current?.defaultValue).toBe('initial content')
|
||||
expect(editorProps.current).not.toHaveProperty('value')
|
||||
})
|
||||
})
|
||||
|
|
@ -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({
|
|||
<Editor
|
||||
height={renderedEditorHeight === null ? '100%' : `${renderedEditorHeight}px`}
|
||||
language={language}
|
||||
value={content}
|
||||
// Why: Orca's mount/layout reconciliation is the sole post-mount content
|
||||
// owner; the wrapper's controlled read-only path would also call setValue.
|
||||
defaultValue={content}
|
||||
theme={isDark ? 'vs-dark' : 'vs'}
|
||||
onChange={handleChange}
|
||||
onMount={handleMount}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { bench, expect } from 'vitest'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { syncContentUpdate } from './monaco-content-sync'
|
||||
|
||||
const SAMPLE_COUNT = 30
|
||||
const WARMUP_COUNT = 5
|
||||
|
||||
function percentile95(samples: readonly number[]): number {
|
||||
const sorted = [...samples].sort((left, right) => left - right)
|
||||
return sorted[Math.ceil(sorted.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
async function settleBetweenArms(): Promise<void> {
|
||||
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<void>((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<void> {
|
||||
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 }
|
||||
)
|
||||
|
|
@ -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<typeof vi.fn>
|
||||
getFullModelRange: ReturnType<typeof vi.fn>
|
||||
pushEditOperations: ReturnType<typeof vi.fn>
|
||||
pushUndoStop: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>('.find-widget')
|
||||
const findInput = findWidget?.querySelector<HTMLInputElement | HTMLTextAreaElement>('.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<HTMLElement>('.matchesCount')?.textContent?.trim() ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
window.__monacoEditorE2E = probe
|
||||
return () => {
|
||||
if (window.__monacoEditorE2E === probe) {
|
||||
delete window.__monacoEditorE2E
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<Awaited<ReturnType<typeof readProbe>>> {
|
||||
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<void> {
|
||||
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<CrashProbe> {
|
||||
return electronApp.evaluate(() => globalThis.__liveLogCrashProbe ?? { processGone: null })
|
||||
}
|
||||
|
||||
async function forceGcAndSettle(page: Page): Promise<void> {
|
||||
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<MemorySample> {
|
||||
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<ReturnType<typeof readProbe>>,
|
||||
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<ReturnType<typeof readProbe>>,
|
||||
current: Awaited<ReturnType<typeof readProbe>>
|
||||
): 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<Awaited<ReturnType<typeof readProbe>> | null> {
|
||||
return page.evaluate(() => {
|
||||
const probe = window.__monacoEditorE2E
|
||||
return probe ? { filePath: probe.filePath, ...probe.snapshot() } : null
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue