Stabilize terminal e2e tests on Windows ConPTY (#5881)

* Stabilize terminal e2e tests on Windows ConPTY

Windows ConPTY can drop or reorder the tail of large synchronized terminal
frames or stdout writes when they are written all at once or when the process
exits too quickly.

* Wait for PTY shell readiness before running test scripts.
* Chunk large table writes line-by-line and yield with a short timeout on
  Windows to let the stream drain.
* Add and poll for a synchronized frame tail marker to ensure the entire
  content is rendered before proceeding.

* Remove flaky preflight shell echo in terminal emoji scroll test

The Ctrl+C/Ctrl+U preflight keys sent by `waitForPtyShellEcho` can race
Windows ConPTY startup and eat subsequent test input. Remove this wait
and rely on the fixture marker as the readiness signal instead.
This commit is contained in:
Jinjing
2026-06-20 00:46:37 -07:00
committed by GitHub
parent 7f7fd86bcb
commit 5622d0e94f
3 changed files with 81 additions and 34 deletions
@@ -126,18 +126,16 @@ outputRows.push('|-' + widths.map((width) => '-'.repeat(width)).join('-|-') + '-
for (let repeat = 0; repeat < 4; repeat += 1) {
for (const row of rows) outputRows.push(line(row))
}
process.stdout.write('\\x1b[?2026h\\x1b[2J\\x1b[H')
let index = 0
const timer = setInterval(() => {
if (index < outputRows.length) {
process.stdout.write(outputRows[index] + '\\n')
index += 1
return
}
clearInterval(timer)
process.stdout.write('LONG_TABLE_SCROLL_RESTORE_${runId}\\n')
process.stdout.write('\\x1b[?2026l')
}, 8)
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const row of outputRows) {
await writeStdout(row + '\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('LONG_TABLE_SCROLL_RESTORE_${runId}\\n')
`
}
@@ -250,6 +248,10 @@ function renderRow(cells) {
}
return rows
}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
const parsedRows = table
.split(/\\r?\\n/)
.filter((row) => row.trim().startsWith('|') && !isSeparatorRow(row))
@@ -259,12 +261,13 @@ for (const [index, row] of parsedRows.entries()) {
rendered.push(...renderRow(row))
rendered.push(rule(index === parsedRows.length - 1 ? border.bottom : border.middle))
}
process.stdout.write('\\x1b[?2026h\\x1b[2J\\x1b[H')
process.stdout.write(rendered.join('\\r\\n'))
process.stdout.write('\\r\\n')
process.stdout.write('\\r\\n${widthMarker}:' + generatedTableWidth + '\\r\\n')
process.stdout.write('\\r\\n${marker}\\r\\n')
process.stdout.write('\\x1b[?2026l')
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const line of rendered) {
await writeStdout(line + '\\r\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('${widthMarker}:' + generatedTableWidth + '\\r\\n')
await writeStdout('${marker}\\r\\n')
`
}
@@ -286,12 +289,16 @@ function narrowSignerMarkdownTableScript(runId: string): string {
).flat()
return `
const rows = ${JSON.stringify(repeatedRows)}
process.stdout.write('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const row of rows) {
process.stdout.write(row + '\\r\\n')
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
process.stdout.write('${marker}\\r\\n')
process.stdout.write('\\x1b[?2026l')
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const row of rows) {
await writeStdout(row + '\\r\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('${marker}\\r\\n')
`
}
@@ -10,6 +10,7 @@ import {
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
import { waitForPtyShellEcho } from './terminal-pty-readiness'
import {
analyzeRasterCursorCells,
type TerminalRasterProbeTarget
@@ -59,11 +60,20 @@ function emojiTableScript(marker: string): string {
].join('\r\n')
return `
process.stdout.write('\\x1b[?2026h\\x1b[?25l')
process.stdout.write('\\x1b[2J\\x1b[H')
process.stdout.write(${JSON.stringify(table)})
process.stdout.write('\\r\\n${marker}\\r\\n')
process.stdout.write('\\x1b[?25h\\x1b[?2026l')
async function writeStdout(chunk) {
await new Promise((resolve) => {
process.stdout.write(chunk, resolve)
})
if (process.platform === 'win32') {
await new Promise((resolve) => setTimeout(resolve, 8))
}
}
await writeStdout('\\x1b[?2026h\\x1b[?25l\\x1b[2J\\x1b[H')
for (const line of ${JSON.stringify(table.split('\r\n'))}) {
await writeStdout(line + '\\r\\n')
}
await writeStdout('\\x1b[?25h\\x1b[?2026l')
await writeStdout('${marker}\\r\\n')
setTimeout(() => process.exit(0), 50)
`
}
@@ -254,6 +264,7 @@ test.describe('OpenCode emoji table terminal rendering', () => {
await enableRiskyTerminalRendererPath(orcaPage)
const ptyId = await waitForActivePanePtyId(orcaPage)
await waitForPtyShellEcho(orcaPage, ptyId, 20_000)
const runId = randomUUID()
const marker = `${EMOJI_TABLE_MARKER}_${runId}`
const scriptPath = path.join(testRepoPath, `.orca-opencode-emoji-table-${runId}.mjs`)
@@ -62,6 +62,7 @@ const RAW_EMOJI_BOX_TABLE_WIDTH =
function rawEmojiFixtureBoxTableScript(table: string, runId: string): string {
const marker = `RAW_EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
const frameTailMarker = rawEmojiFixtureFrameTailMarker(runId)
return `
const table = ${JSON.stringify(table)}
const widths = ${JSON.stringify(RAW_EMOJI_BOX_TABLE_COLUMN_WIDTHS)}
@@ -131,12 +132,23 @@ function renderRow(cells) {
border.vertical
)
}
// Why: Windows can lose the tail of large stdout writes if this fixture exits
// immediately, which hides the completion marker behind a shell prompt.
function writeStdout(chunk) {
return new Promise((resolve) => {
// Why: Windows ConPTY can drop or reorder the tail of one large synchronized
// frame. Drain and yield between chunks so the golden waits on transport, not luck.
async function writeStdout(chunk) {
await new Promise((resolve) => {
process.stdout.write(chunk, resolve)
})
if (process.platform === 'win32') {
await new Promise((resolve) => setTimeout(resolve, 8))
}
}
async function writeStdoutLine(line) {
await writeStdout(line + '\\r\\n')
}
async function flushStdout() {
return new Promise((resolve) => {
process.stdout.write('', resolve)
})
}
const parsedRows = table
.split(/\\r?\\n/)
@@ -148,9 +160,13 @@ for (const [index, row] of parsedRows.entries()) {
rendered.push(rule(index === parsedRows.length - 1 ? border.bottom : border.middle))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
await writeStdout(rendered.join('\\r\\n'))
await writeStdout('\\r\\n${marker}\\r\\n')
for (const line of rendered) {
await writeStdoutLine(line)
}
await writeStdoutLine('${frameTailMarker}')
await writeStdout('\\x1b[?2026l')
await writeStdoutLine('${marker}')
await flushStdout()
`
}
@@ -158,6 +174,10 @@ function rawEmojiFixtureCompletionMarker(runId: string): string {
return `RAW_EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
}
function rawEmojiFixtureFrameTailMarker(runId: string): string {
return `RAW_EMOJI_FIXTURE_TABLE_FRAME_TAIL_${runId}`
}
async function setWideRenderedTableViewport(page: Page): Promise<void> {
await page.setViewportSize({ width: 1480, height: 820 })
await page.waitForTimeout(250)
@@ -499,11 +519,20 @@ test.describe('Terminal raw emoji table scroll restore repro', () => {
try {
const completionMarker = rawEmojiFixtureCompletionMarker(runId)
const frameTailMarker = rawEmojiFixtureFrameTailMarker(runId)
// Why: the fixture marker is the shell-readiness signal here; an extra
// Ctrl+C/Ctrl+U preflight can race Windows ConPTY startup and eat input.
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
// Why: Windows ConPTY can return the PowerShell prompt while xterm is
// still flushing synchronized output if the pane is hidden immediately.
// This golden is about restored table geometry, not shell-flush timing.
await waitForTerminalOutput(orcaPage, completionMarker, 20_000, 30_000)
await expect
.poll(() => getTerminalContent(orcaPage, 30_000), {
timeout: 10_000,
message: 'raw emoji table synchronized frame tail was not rendered'
})
.toContain(frameTailMarker)
await switchToWorktree(orcaPage, secondWorktreeId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await orcaPage.waitForTimeout(1_000)