mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
* fix(terminal): serialize only the visible width after a column shrink xterm does not reflow the alternate buffer (or a normal buffer under pre-21376 ConPTY), so after a shrink each line keeps its old length. SerializeAddon walked every non-final row to line.length, so any snapshot taken after a shrink carried stale right-hand cells that wrapped into extra rows on replay; restores repainted that garbage and a differential TUI such as OpenCode never cleared it. Clamp the row walk and the wrap-boundary lookups to the terminal's columns in Orca's addon-serialize source patch, and regenerate the bundles, maps and lockfile hash per docs/reference/xterm-patch-regeneration.md. * test(terminal): read shrink-snapshot fixtures through public APIs Drops the private-terminal casts the casting gate flags; the normal-buffer case now drives a plain pre-21376 ConPTY terminal and its SerializeAddon directly. * fix(terminal): blank a wide glyph clipped by a column shrink when serializing After a non-reflowing shrink a width-2 glyph can have its lead half in the last column and its trailing half past the grid. Serializing the lead half makes the replay wrap it to the next row and shift every row below, so serialize that cell as a blank and keep the row exactly the grid's width. A glyph ending exactly at the edge is unchanged. * test(mobile): move the session closure pin past the main agent status modules #22452 added src/shared/main-agent-status.ts and src/shared/agent-turn-outcome.ts, which agent-status-types.ts imports, so the session route's closure grew by two local modules (4218 -> 4220). That change was src/shared-only, so its own CI never ran this suite; main has been at 4220 since, and any PR that fires the mobile web app job fails on the stale pin. Measured on4064653740and on origin/main3ea15dd0a2. * test(terminal): differential serialize round-trip fuzz and transcript replay Seeded VT streams (text, CJK/emoji/combining, SGR, cursor/edit ops, scroll regions, DECAWM/IRM, alt-screen variants, DECSC, and shrink-heavy resizes) drive a source terminal in three modes: reflowing normal buffer, alternate buffer, and a non-reflowing pre-21376 ConPTY normal buffer. At each checkpoint every SerializeAddon build under test serializes it, and each output is replayed into a fresh terminal of the same size and compared cell by cell, plus cursor, active buffer and modes. CI runs 25 seeds per mode against a pinned list of pre-existing divergences, and replays the committed PTY transcripts (the existing agent fixtures plus new vim, less, pico, and OpenCode captures) under four resize schedules. Point ORCA_OLD_SERIALIZE_ADDON at a previous patched build to also check byte identity when no line is wider than the grid, and that no checkpoint regresses. * test(terminal): build the differential serialize baseline from any git ref config/scripts/build-serialize-addon-at-ref.mjs reverse-applies the patch that produced the installed @xterm/addon-serialize dist, applies the ref's patch, and verifies each step against the patches' blob hashes, so the fuzz can use origin/main (or any fix commit) as its baseline without a second install. ORCA_NEW_SERIALIZE_ADDON swaps in a built dist for the build under test, and a seed-pinned test replays the nine I3 regressions found against origin/main. * fix(terminal): serialize a clipped wide glyph as a width-1 blank The stand-in for a wide glyph clipped by a column shrink came from getNullCell(), whose width is 0. _nextCell skipped it as a wide trailer, and the row-end wrap check counted the width-0 _backgroundCell as content, so a soft wrap after the clipped column was taken as natural and replayed one column early (conpty seed 1149: `abcdefghi中WRAPPED` at 12 -> 10 cols replayed as `abcdefghiR`/`APPED`). Blank the cell in place instead: width 1, no codepoint, its own attributes, so it counts as one empty column and forces the wrap. Differential sweep vs origin/main, 7000 cases per mode: I1 0 byte diffs, seed 1149 fixed; the remaining I3 regressions are the trailing background-row seeds. * test(terminal): neutral paths in the serialize fixtures and usage comment The OpenCode transcript carried this machine's lane paths in its footer; replace them with same-length neutral paths so the recorded cursor layout is unchanged. * fix(terminal): keep trailing background-only rows when serializing Without scrollback, _serializeString trims rows after the last content cursor. A row made only of background-colored blanks emits its erase in _rowEnd but never moved that cursor, so two or more such rows at the bottom were dropped (4x3 `r1\r\n\e[48;5;157m\e[J\e[0m\e[3;1H` replayed with the last row blank). Track the erase separately and extend the kept rows to it, except when the cursor is wrap-pending: relative moves back from those rows cannot re-create that state, and doing so regressed normal 508, alt 1425/6647, conpty 4699. Harness: I1 now exempts checkpoints with a background row after the last text row, the one place this fix changes bytes on purpose (scope helpers move to serialize-grid-variant-scope.ts); conpty seed 5 leaves the pinned pre-existing list. Sweep vs origin/main, 7000 cases per mode: I1 0, I3 regressions 0; fixed/both-fail normal 967/3914, alt 2628/8316, conpty 6657/2937 (was 323/4558, 2296/8648, 5466/4128). * test(terminal): narrow the I1 carve-out to where the serializer keeps background rows Trailing background-only rows change bytes only when the serialized range has no scrollback (the trimming path) and the cursor is not wrap-pending; checkpoints with scrollback or a wrap-pending cursor are held to byte identity again. The 7000-per-mode sweep against origin/main stays at I1 0 and I3 0. * test(native-chat): pin that screen scrapers ignore kept background rows The serializer now keeps trailing background-only rows, so a painted TUI's screen read as text ends in \r\n\x1b[NX rows. Serialize the same frame with and without them and check the Claude option scrape, the empty-prompt check and the fork transcript read the same thing. * test(terminal): read xterm core internals through a checked parser, not Reflect.get The fuzz oracle reached xterm's private _core with Reflect.get, which the low-evidence gate rejects. Narrow _core, writeSync and the DECSTBM bounds with in/typeof checks into one named XtermCoreInternals shape instead. * test(terminal): keep captured serialize transcripts byte-exact on Windows checkouts
124 lines
4.5 KiB
JavaScript
124 lines
4.5 KiB
JavaScript
#!/usr/bin/env node
|
|
// Materializes the patched @xterm/addon-serialize dist of any git ref, for the
|
|
// differential serialize fuzz (src/main/daemon/serialize-grid.differential.fuzz.test.ts):
|
|
//
|
|
// node config/scripts/build-serialize-addon-at-ref.mjs --ref origin/main --out-dir /tmp/serialize-old
|
|
// ORCA_OLD_SERIALIZE_ADDON=<printed path> pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/serialize-grid.differential.fuzz.test.ts
|
|
//
|
|
// It recovers the pristine dist by reverse-applying whichever patch produced the
|
|
// installed node_modules copy, applies the ref's patch, and checks every step
|
|
// against the blob hashes in the patches' index lines.
|
|
|
|
import { execFileSync } from 'node:child_process'
|
|
import { createHash } from 'node:crypto'
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmSync,
|
|
writeFileSync
|
|
} from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
|
|
const PACKAGE = '@xterm/addon-serialize'
|
|
const DIST_FILE = 'lib/addon-serialize.js'
|
|
|
|
function parseArgs(argv) {
|
|
const args = {}
|
|
for (let i = 0; i < argv.length; i += 2) {
|
|
args[argv[i].replace(/^--/, '')] = argv[i + 1]
|
|
}
|
|
if (!args.ref || !args['out-dir']) {
|
|
throw new Error('usage: build-serialize-addon-at-ref.mjs --ref <git-ref> --out-dir <dir>')
|
|
}
|
|
return args
|
|
}
|
|
|
|
function git(args, options = {}) {
|
|
return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...options })
|
|
}
|
|
|
|
function blobHash(content) {
|
|
return createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex')
|
|
}
|
|
|
|
function patchFileName() {
|
|
const name = readdirSync(path.join(REPO_ROOT, 'config', 'patches')).find(
|
|
(file) => file.startsWith('@xterm__addon-serialize@') && file.endsWith('.patch')
|
|
)
|
|
if (!name) {
|
|
throw new Error('no @xterm/addon-serialize dist patch under config/patches')
|
|
}
|
|
return name
|
|
}
|
|
|
|
/** Pre- and post-image blob hashes the patch records for the dist file. */
|
|
function distHashes(patchText) {
|
|
const match = patchText.match(
|
|
new RegExp(`diff --git a/${DIST_FILE} b/${DIST_FILE}\\nindex ([0-9a-f]+)\\.\\.([0-9a-f]+)`)
|
|
)
|
|
if (!match) {
|
|
throw new Error(`patch does not touch ${DIST_FILE}`)
|
|
}
|
|
return { pristine: match[1], patched: match[2] }
|
|
}
|
|
|
|
function applyPatch(packageDir, patchPath, reverse) {
|
|
// Ceiling stops git from discovering an enclosing repository, so paths stay package-relative.
|
|
git(['apply', ...(reverse ? ['-R'] : []), `--include=${DIST_FILE}`, patchPath], {
|
|
cwd: packageDir,
|
|
env: { ...process.env, GIT_CEILING_DIRECTORIES: path.dirname(packageDir) }
|
|
})
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2))
|
|
const outDir = path.resolve(args['out-dir'])
|
|
const name = patchFileName()
|
|
const relativePatch = path.posix.join('config', 'patches', name)
|
|
const refPatch = git(['show', `${args.ref}:${relativePatch}`], { cwd: REPO_ROOT })
|
|
const target = distHashes(refPatch)
|
|
|
|
const packageDir = path.join(outDir, 'package')
|
|
rmSync(outDir, { recursive: true, force: true })
|
|
mkdirSync(path.join(packageDir, 'lib'), { recursive: true })
|
|
const installed = path.join(REPO_ROOT, 'node_modules', PACKAGE, DIST_FILE)
|
|
cpSync(installed, path.join(packageDir, DIST_FILE))
|
|
const distPath = path.join(packageDir, DIST_FILE)
|
|
const installedHash = blobHash(readFileSync(distPath))
|
|
|
|
if (installedHash !== target.patched) {
|
|
const candidates = [
|
|
readFileSync(path.join(REPO_ROOT, relativePatch), 'utf8'),
|
|
git(['show', `HEAD:${relativePatch}`], { cwd: REPO_ROOT })
|
|
]
|
|
const source = candidates.find((text) => distHashes(text).patched === installedHash)
|
|
if (!source) {
|
|
throw new Error(
|
|
`installed ${DIST_FILE} (${installedHash}) matches neither the working-tree nor the HEAD patch; run pnpm install`
|
|
)
|
|
}
|
|
const sourcePath = path.join(outDir, 'installed.patch')
|
|
writeFileSync(sourcePath, source)
|
|
applyPatch(packageDir, sourcePath, true)
|
|
if (blobHash(readFileSync(distPath)) !== target.pristine) {
|
|
throw new Error(`reverse-applied dist is not the pristine ${target.pristine} the ref patches`)
|
|
}
|
|
const refPatchPath = path.join(outDir, 'ref.patch')
|
|
writeFileSync(refPatchPath, refPatch)
|
|
applyPatch(packageDir, refPatchPath, false)
|
|
}
|
|
const finalHash = blobHash(readFileSync(distPath))
|
|
if (finalHash !== target.patched || !existsSync(distPath)) {
|
|
throw new Error(
|
|
`built ${DIST_FILE} hashes to ${finalHash}, the ref patch expects ${target.patched}`
|
|
)
|
|
}
|
|
process.stdout.write(`${distPath}\n`)
|
|
}
|
|
|
|
main()
|