test(terminal): run the renderer's OSC 7 agreement cases through xterm (#35661)

* test(terminal): run the renderer's OSC 7 agreement cases through xterm

The renderer's OSC 7 parser only ever sees what xterm's OSC parser hands
its handler, and xterm drops raw C0 bytes first: a raw tab, LF or CR
becomes /tmp/ab, and a raw NUL is dropped rather than refused. The
agreement table now records those rows as a known renderer gap (viaXterm)
and the renderer side runs every case through @xterm/headless.

* test(terminal): pin raw ESC, CAN and SUB, and run the daemon side through its PTY scanner

- A raw ESC ends xterm's OSC at /tmp/a, and the daemon's scanner rejects
  the report (ESC not followed by \), so the pane keeps its folder.
- CAN and SUB cancel the sequence in xterm, so the renderer's handler
  never runs (no-report); the daemon reads the byte as part of the path.
- The daemon side now also runs every case through
  TerminalOscCwdTitleScanner after a prior good report, as PTY traffic
  reaches it, alongside the direct-parse check.
This commit is contained in:
Neil
2026-10-28 21:51:12 -07:00
committed by GitHub
parent 502413d749
commit 23a35c2946
3 changed files with 112 additions and 21 deletions
@@ -4,7 +4,11 @@ import {
parseFileUriPathParts,
type ParseFileUriPathOptions
} from './osc7-file-uri'
import { OSC7_PARSER_AGREEMENT_CASES } from '../../shared/osc7-parser-agreement-cases'
import {
OSC7_KEEPS_FOLDER,
OSC7_PARSER_AGREEMENT_CASES
} from '../../shared/osc7-parser-agreement-cases'
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
const FLAVORS: Record<string, ParseFileUriPathOptions> = {
posix: { pathFlavor: 'posix' },
@@ -291,7 +295,19 @@ describe('OSC 7 file URIs from nushell', () => {
})
describe('OSC 7 file URIs both parsers must agree on (daemon side)', () => {
it.each(OSC7_PARSER_AGREEMENT_CASES)('%s', (_case, uri, path) => {
expect(parseFileUriPath(uri, { pathFlavor: 'posix' })).toBe(path)
it.each(OSC7_PARSER_AGREEMENT_CASES)('$name, as a payload', ({ uri, folder }) => {
expect(parseFileUriPath(uri, { pathFlavor: 'posix' })).toBe(folder)
})
// Real traffic: the daemon tracks the pane's folder from raw PTY bytes through this scanner.
it.each(OSC7_PARSER_AGREEMENT_CASES)(
'$name, through the PTY scanner',
({ uri, folder, viaScanner }) => {
const scanner = new TerminalOscCwdTitleScanner({ pathFlavor: 'posix' })
scanner.scan('\x1b]7;file:///before\x07')
scanner.scan(`\x1b]7;${uri}\x07`)
const kept = viaScanner === OSC7_KEEPS_FOLDER || folder === null
expect(scanner.cwd).toBe(kept ? '/before' : folder)
}
)
})
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest'
import { Terminal } from '@xterm/headless'
import { OSC7_PARSER_AGREEMENT_CASES } from '../../../../shared/osc7-parser-agreement-cases'
import { parseOsc7 } from './parse-osc7'
@@ -68,7 +69,29 @@ describe('parseOsc7', () => {
})
describe('OSC 7 payloads both parsers must agree on (renderer side)', () => {
it.each(OSC7_PARSER_AGREEMENT_CASES)('%s', (_case, uri, path) => {
expect(parseOsc7(uri)).toBe(path)
it.each(OSC7_PARSER_AGREEMENT_CASES)('$name, as a payload', ({ uri, folder }) => {
expect(parseOsc7(uri)).toBe(folder)
})
// Real traffic: the renderer only sees what xterm's OSC parser delivers.
it.each(OSC7_PARSER_AGREEMENT_CASES)(
'$name, through xterm',
async ({ uri, folder, viaXterm, xtermCancels }) => {
const term = new Terminal({ allowProposedApi: true })
const payloads: string[] = []
term.parser.registerOscHandler(7, (data) => {
payloads.push(data)
return true
})
await new Promise<void>((resolve) => term.write(`\x1b]7;${uri}\x07`, resolve))
term.dispose()
if (xtermCancels) {
expect(payloads).toEqual([])
return
}
expect(payloads.map((data) => parseOsc7(data))).toEqual([
viaXterm === undefined ? folder : viaXterm
])
}
)
})
+68 -16
View File
@@ -1,17 +1,69 @@
// One report must name one folder in both OSC 7 parsers: the daemon's (checkpoints, restores) and
// the renderer's (split, new terminal). Each side's tests run these cases, including what nushell
// sends raw.
export const OSC7_PARSER_AGREEMENT_CASES: readonly [string, string, string | null][] = [
['a raw tab', 'file://Desktop/tmp/a\tb', '/tmp/a\tb'],
['a raw newline', 'file://Desktop/tmp/a\nb', '/tmp/a\nb'],
['a raw carriage return', 'file://Desktop/tmp/a\rb', '/tmp/a\rb'],
['a trailing space', 'file://Desktop/tmp/a ', '/tmp/a '],
['a space inside', 'file://Desktop/tmp/a b', '/tmp/a b'],
['a lone %', 'file://Desktop/tmp/100%', '/tmp/100%'],
['a # and ?', 'file://Desktop/tmp/h#a?b', '/tmp/h#a?b'],
['an encoded newline', 'file:///tmp/a%0Ab', '/tmp/a\nb'],
['a leading raw NUL', 'file:///\u0000tmp', null],
['a trailing raw NUL', 'file:///tmp/a\u0000', null],
['an encoded NUL', 'file:///tmp/%00', null],
['non-UTF-8 hex', 'file:///tmp/%FF', null]
// One report must name one folder in both OSC 7 parsers: the daemon's (checkpoints, restores) reads
// raw PTY bytes; the renderer's (split, new terminal) reads what xterm's OSC parser hands it. Each
// side's tests run these cases, including what nushell sends raw.
/** No report reached the parser, or it was refused: the pane keeps the folder it had. */
export const OSC7_KEEPS_FOLDER = 'keeps-folder'
export type Osc7AgreementCase = {
name: string
/** The report as the shell writes it, between `ESC ]7;` and BEL. */
uri: string
/** What both parsers name for this payload, or null when they refuse it. */
folder: string | null
/**
* Known renderer gap: xterm's OSC parser drops raw C0 bytes, ends the sequence at a raw ESC and
* cancels it at CAN or SUB, before the renderer sees the payload.
*/
viaXterm?: string | null
/** xterm cancels the sequence (CAN, SUB), so the renderer's OSC 7 handler never runs. */
xtermCancels?: true
/**
* The daemon's PTY scanner, when it differs from `folder`: a raw ESC that is not ST invalidates
* the report.
*/
viaScanner?: typeof OSC7_KEEPS_FOLDER
}
export const OSC7_PARSER_AGREEMENT_CASES: readonly Osc7AgreementCase[] = [
{ name: 'a raw tab', uri: 'file://Desktop/tmp/a\tb', folder: '/tmp/a\tb', viaXterm: '/tmp/ab' },
{
name: 'a raw newline',
uri: 'file://Desktop/tmp/a\nb',
folder: '/tmp/a\nb',
viaXterm: '/tmp/ab'
},
{
name: 'a raw carriage return',
uri: 'file://Desktop/tmp/a\rb',
folder: '/tmp/a\rb',
viaXterm: '/tmp/ab'
},
{ name: 'a trailing space', uri: 'file://Desktop/tmp/a ', folder: '/tmp/a ' },
{ name: 'a space inside', uri: 'file://Desktop/tmp/a b', folder: '/tmp/a b' },
{ name: 'a lone %', uri: 'file://Desktop/tmp/100%', folder: '/tmp/100%' },
{ name: 'a # and ?', uri: 'file://Desktop/tmp/h#a?b', folder: '/tmp/h#a?b' },
{ name: 'an encoded newline', uri: 'file:///tmp/a%0Ab', folder: '/tmp/a\nb' },
{ name: 'a leading raw NUL', uri: 'file:///\u0000tmp', folder: null, viaXterm: '/tmp' },
{ name: 'a trailing raw NUL', uri: 'file:///tmp/a\u0000', folder: null, viaXterm: '/tmp/a' },
{ name: 'an encoded NUL', uri: 'file:///tmp/%00', folder: null },
{ name: 'non-UTF-8 hex', uri: 'file:///tmp/%FF', folder: null },
{
name: 'a raw ESC',
uri: 'file:///tmp/a\u001bb',
folder: '/tmp/a\u001bb',
viaXterm: '/tmp/a',
viaScanner: OSC7_KEEPS_FOLDER
},
{
name: 'a raw CAN',
uri: 'file:///tmp/a\u0018b',
folder: '/tmp/a\u0018b',
xtermCancels: true
},
{
name: 'a raw SUB',
uri: 'file:///tmp/a\u001ab',
folder: '/tmp/a\u001ab',
xtermCancels: true
}
]