@silvery/tea 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +54 -0
- package/src/core/index.ts +225 -0
- package/src/core/slice.ts +69 -0
- package/src/create-command-registry.ts +106 -0
- package/src/effects.ts +145 -0
- package/src/focus-events.ts +243 -0
- package/src/focus-manager.ts +491 -0
- package/src/focus-queries.ts +241 -0
- package/src/index.ts +213 -0
- package/src/keys.ts +1382 -0
- package/src/pipe.ts +110 -0
- package/src/plugins.ts +119 -0
- package/src/store/index.ts +306 -0
- package/src/streams/index.ts +405 -0
- package/src/tea/README.md +208 -0
- package/src/tea/index.ts +174 -0
- package/src/text-cursor.ts +206 -0
- package/src/text-decorations.ts +253 -0
- package/src/text-ops.ts +150 -0
- package/src/tree-utils.ts +27 -0
- package/src/types.ts +670 -0
- package/src/with-commands.ts +337 -0
- package/src/with-diagnostics.ts +955 -0
- package/src/with-dom-events.ts +168 -0
- package/src/with-focus.ts +162 -0
- package/src/with-keybindings.ts +180 -0
- package/src/with-react.ts +92 -0
- package/src/with-render.ts +92 -0
- package/src/with-terminal.ts +219 -0
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withDiagnostics - Plugin for buffer and rendering diagnostic checks
|
|
3
|
+
*
|
|
4
|
+
* Wraps the `cmd` object to check invariants after command execution:
|
|
5
|
+
* - All commands: Check incremental vs fresh render
|
|
6
|
+
* - Cursor moves: Also check buffer content stability
|
|
7
|
+
* - Optional: ANSI replay verification (characters AND SGR styles)
|
|
8
|
+
*
|
|
9
|
+
* ## Design Note: Why wrap `cmd` instead of `sendInput`?
|
|
10
|
+
*
|
|
11
|
+
* The two approaches are complementary:
|
|
12
|
+
*
|
|
13
|
+
* 1. **`sendInput()` level** (in renderer.ts) — Already has `SILVERY_CHECK_INCREMENTAL`
|
|
14
|
+
* which catches ALL inputs regardless of how they arrive (raw key presses,
|
|
15
|
+
* type(), press(), etc.). This is the right place for incremental render checks.
|
|
16
|
+
*
|
|
17
|
+
* 2. **`cmd` level** (this plugin) — Command-aware, can selectively check stability
|
|
18
|
+
* for cursor moves only. Raw sendInput doesn't know which inputs are cursor
|
|
19
|
+
* commands that should preserve content. Another option would be `withInput()`
|
|
20
|
+
* which could wrap sendInput with awareness of what input was sent.
|
|
21
|
+
*
|
|
22
|
+
* This plugin focuses on the command-aware checks. For comprehensive incremental
|
|
23
|
+
* render checking, use `SILVERY_STRICT=1` environment variable which enables all checks.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* import { withDiagnostics } from '@silvery/term/toolbelt';
|
|
28
|
+
*
|
|
29
|
+
* // All checks enabled by default when you call withDiagnostics()
|
|
30
|
+
* const driver = withDiagnostics(createBoardDriver(repo, rootId));
|
|
31
|
+
*
|
|
32
|
+
* // Or disable specific checks
|
|
33
|
+
* const driver = withDiagnostics(createBoardDriver(repo, rootId), {
|
|
34
|
+
* checkReplay: false // skip ANSI replay check
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* // Commands now run invariant checks automatically
|
|
38
|
+
* await driver.cmd.down(); // Checks incremental + stability + replay
|
|
39
|
+
* await driver.cmd.search(); // Checks incremental + replay
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { mkdir } from "node:fs/promises"
|
|
44
|
+
import { join } from "node:path"
|
|
45
|
+
import { type Color, type TerminalBuffer, colorEquals } from "@silvery/term/buffer"
|
|
46
|
+
import { outputPhase } from "@silvery/term/pipeline"
|
|
47
|
+
import { compareBuffers, formatMismatch } from "@silvery/test/compare-buffers"
|
|
48
|
+
import type { BoxProps, TeaNode } from "./types"
|
|
49
|
+
import type { AppWithCommands, Cmd, Command } from "./with-commands"
|
|
50
|
+
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// Types
|
|
53
|
+
// =============================================================================
|
|
54
|
+
|
|
55
|
+
export interface DiagnosticOptions {
|
|
56
|
+
/** Check incremental vs fresh render (default: true when plugin is used) */
|
|
57
|
+
checkIncremental?: boolean
|
|
58
|
+
/** Check buffer stability for cursor commands (default: true when plugin is used) */
|
|
59
|
+
checkStability?: boolean
|
|
60
|
+
/** Check ANSI replay produces correct result (default: true when plugin is used) */
|
|
61
|
+
checkReplay?: boolean
|
|
62
|
+
/** Check layout tree integrity after each command (default: true when plugin is used) */
|
|
63
|
+
checkLayout?: boolean
|
|
64
|
+
/** Lines to skip for stability check (e.g., [0, -1] for breadcrumb/statusbar) */
|
|
65
|
+
skipLines?: number[]
|
|
66
|
+
/** Capture screenshot on failure (default: false) */
|
|
67
|
+
captureOnFailure?: boolean
|
|
68
|
+
/** Directory for failure screenshots (default: /tmp/silvery-diagnostics) */
|
|
69
|
+
screenshotDir?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Text mismatch between before and after states
|
|
74
|
+
*/
|
|
75
|
+
interface TextMismatch {
|
|
76
|
+
line: number
|
|
77
|
+
before: string
|
|
78
|
+
after: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* ANSI replay mismatch (character content)
|
|
83
|
+
*/
|
|
84
|
+
interface ReplayMismatch {
|
|
85
|
+
x: number
|
|
86
|
+
y: number
|
|
87
|
+
expected: string
|
|
88
|
+
actual: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* SGR style attributes tracked per cell in the virtual terminal.
|
|
93
|
+
*/
|
|
94
|
+
interface VTermStyle {
|
|
95
|
+
fg: number | { r: number; g: number; b: number } | null
|
|
96
|
+
bg: number | { r: number; g: number; b: number } | null
|
|
97
|
+
bold: boolean
|
|
98
|
+
dim: boolean
|
|
99
|
+
italic: boolean
|
|
100
|
+
underline: boolean
|
|
101
|
+
blink: boolean
|
|
102
|
+
inverse: boolean
|
|
103
|
+
hidden: boolean
|
|
104
|
+
strikethrough: boolean
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* ANSI replay style mismatch
|
|
109
|
+
*/
|
|
110
|
+
interface StyleMismatch {
|
|
111
|
+
x: number
|
|
112
|
+
y: number
|
|
113
|
+
char: string
|
|
114
|
+
diffs: string[]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// =============================================================================
|
|
118
|
+
// VirtualTerminal - ANSI Replay Simulator
|
|
119
|
+
// =============================================================================
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Virtual terminal simulator for testing ANSI replay equivalence.
|
|
123
|
+
*
|
|
124
|
+
* Parses ANSI sequences and applies them to a 2D grid tracking both
|
|
125
|
+
* character content and SGR style attributes (fg, bg, bold, italic, etc.).
|
|
126
|
+
* Used to verify the Replay Equivalence invariant: applying the ANSI
|
|
127
|
+
* diff to the previous buffer state should produce the target buffer.
|
|
128
|
+
*
|
|
129
|
+
* Handles:
|
|
130
|
+
* - Cursor positioning (H, G, A, B, C, D)
|
|
131
|
+
* - Line clear (K)
|
|
132
|
+
* - Wide characters (emojis, CJK)
|
|
133
|
+
* - CR/LF
|
|
134
|
+
* - SGR style sequences (m) — fg/bg colors, text attributes
|
|
135
|
+
*/
|
|
136
|
+
export class VirtualTerminal {
|
|
137
|
+
private grid: string[][]
|
|
138
|
+
private wideMarker: boolean[][]
|
|
139
|
+
private styles: VTermStyle[][]
|
|
140
|
+
private sgr: VTermStyle
|
|
141
|
+
private cursorX = 0
|
|
142
|
+
private cursorY = 0
|
|
143
|
+
|
|
144
|
+
constructor(
|
|
145
|
+
public readonly width: number,
|
|
146
|
+
public readonly height: number,
|
|
147
|
+
) {
|
|
148
|
+
this.grid = Array.from({ length: height }, () => Array(width).fill(" "))
|
|
149
|
+
this.wideMarker = Array.from({ length: height }, () => Array(width).fill(false))
|
|
150
|
+
this.styles = Array.from({ length: height }, () => Array.from({ length: width }, () => createDefaultVTermStyle()))
|
|
151
|
+
this.sgr = createDefaultVTermStyle()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Initialize grid from a TerminalBuffer (for incremental replay).
|
|
156
|
+
* Loads both character content and style attributes.
|
|
157
|
+
*/
|
|
158
|
+
loadFromBuffer(buffer: TerminalBuffer): void {
|
|
159
|
+
for (let y = 0; y < Math.min(this.height, buffer.height); y++) {
|
|
160
|
+
for (let x = 0; x < Math.min(this.width, buffer.width); x++) {
|
|
161
|
+
if (buffer.isCellContinuation(x, y)) {
|
|
162
|
+
this.wideMarker[y]![x] = true
|
|
163
|
+
this.grid[y]![x] = ""
|
|
164
|
+
// Continuation cells have default style
|
|
165
|
+
this.styles[y]![x] = createDefaultVTermStyle()
|
|
166
|
+
} else {
|
|
167
|
+
this.grid[y]![x] = buffer.getCellChar(x, y)
|
|
168
|
+
this.wideMarker[y]![x] = false
|
|
169
|
+
// Load style from buffer cell
|
|
170
|
+
const cell = buffer.getCell(x, y)
|
|
171
|
+
this.styles[y]![x] = {
|
|
172
|
+
fg: cell.fg,
|
|
173
|
+
bg: cell.bg,
|
|
174
|
+
bold: !!cell.attrs.bold,
|
|
175
|
+
dim: !!cell.attrs.dim,
|
|
176
|
+
italic: !!cell.attrs.italic,
|
|
177
|
+
underline: !!cell.attrs.underline || !!cell.attrs.underlineStyle,
|
|
178
|
+
blink: !!cell.attrs.blink,
|
|
179
|
+
inverse: !!cell.attrs.inverse,
|
|
180
|
+
hidden: !!cell.attrs.hidden,
|
|
181
|
+
strikethrough: !!cell.attrs.strikethrough,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Apply ANSI escape sequence string to the virtual terminal.
|
|
190
|
+
*/
|
|
191
|
+
applyAnsi(ansi: string): void {
|
|
192
|
+
let i = 0
|
|
193
|
+
while (i < ansi.length) {
|
|
194
|
+
if (ansi[i] === "\x1b" && ansi[i + 1] === "[") {
|
|
195
|
+
const match = ansi.slice(i).match(/^\x1b\[([0-9;:?]*)([A-Za-z])/)
|
|
196
|
+
if (match) {
|
|
197
|
+
this.handleCsi(match[1] || "", match[2]!)
|
|
198
|
+
i += match[0].length
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (ansi[i] === "\r") {
|
|
204
|
+
this.cursorX = 0
|
|
205
|
+
i++
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (ansi[i] === "\n") {
|
|
210
|
+
this.cursorY = Math.min(this.cursorY + 1, this.height - 1)
|
|
211
|
+
i++
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Handle multi-byte Unicode characters
|
|
216
|
+
const char = this.extractChar(ansi, i)
|
|
217
|
+
if (this.cursorX < this.width && this.cursorY < this.height) {
|
|
218
|
+
this.grid[this.cursorY]![this.cursorX] = char
|
|
219
|
+
this.wideMarker[this.cursorY]![this.cursorX] = false
|
|
220
|
+
// Apply current SGR state to the cell
|
|
221
|
+
this.styles[this.cursorY]![this.cursorX] = { ...this.sgr }
|
|
222
|
+
this.cursorX++
|
|
223
|
+
|
|
224
|
+
// Wide characters take 2 columns
|
|
225
|
+
if (this.isWideChar(char) && this.cursorX < this.width) {
|
|
226
|
+
this.grid[this.cursorY]![this.cursorX] = ""
|
|
227
|
+
this.wideMarker[this.cursorY]![this.cursorX] = true
|
|
228
|
+
// Continuation cell gets default style
|
|
229
|
+
this.styles[this.cursorY]![this.cursorX] = createDefaultVTermStyle()
|
|
230
|
+
this.cursorX++
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
i += char.length
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check if a character is wide (emoji, CJK, etc).
|
|
239
|
+
*/
|
|
240
|
+
private isWideChar(char: string): boolean {
|
|
241
|
+
if (char.length === 0) return false
|
|
242
|
+
|
|
243
|
+
// Characters with VS16 (U+FE0F) are emoji presentation = 2 columns
|
|
244
|
+
if (char.includes("\uFE0F")) return true
|
|
245
|
+
|
|
246
|
+
const code = char.codePointAt(0) || 0
|
|
247
|
+
|
|
248
|
+
// Emoji ranges
|
|
249
|
+
if (code >= 0x1f300 && code <= 0x1f9ff) return true
|
|
250
|
+
if (code >= 0x2600 && code <= 0x26ff) return true
|
|
251
|
+
if (code >= 0x2700 && code <= 0x27bf) return true
|
|
252
|
+
|
|
253
|
+
// CJK ranges
|
|
254
|
+
if (code >= 0x4e00 && code <= 0x9fff) return true
|
|
255
|
+
if (code >= 0x3000 && code <= 0x303f) return true
|
|
256
|
+
if (code >= 0xff00 && code <= 0xffef) return true
|
|
257
|
+
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Extract a single Unicode character (which may be multiple bytes).
|
|
263
|
+
* Includes VS16 (U+FE0F) if it follows, since VS16 is a presentation selector
|
|
264
|
+
* that modifies the preceding character's rendering width.
|
|
265
|
+
*/
|
|
266
|
+
private extractChar(str: string, start: number): string {
|
|
267
|
+
const code = str.codePointAt(start)
|
|
268
|
+
if (code === undefined) return str[start] || ""
|
|
269
|
+
let char: string
|
|
270
|
+
if (code > 0xffff) {
|
|
271
|
+
char = String.fromCodePoint(code)
|
|
272
|
+
} else {
|
|
273
|
+
char = str[start] || ""
|
|
274
|
+
}
|
|
275
|
+
// Absorb VS16 (U+FE0F) if it follows — it's a presentation modifier
|
|
276
|
+
const nextIdx = start + char.length
|
|
277
|
+
if (nextIdx < str.length && str.codePointAt(nextIdx) === 0xfe0f) {
|
|
278
|
+
char += "\uFE0F"
|
|
279
|
+
}
|
|
280
|
+
return char
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private handleCsi(params: string, cmd: string): void {
|
|
284
|
+
switch (cmd) {
|
|
285
|
+
case "H": {
|
|
286
|
+
const parts = params.split(";")
|
|
287
|
+
this.cursorY = Math.max(0, (Number.parseInt(parts[0] || "1", 10) || 1) - 1)
|
|
288
|
+
this.cursorX = Math.max(0, (Number.parseInt(parts[1] || "1", 10) || 1) - 1)
|
|
289
|
+
break
|
|
290
|
+
}
|
|
291
|
+
case "G": {
|
|
292
|
+
this.cursorX = Math.max(0, (Number.parseInt(params || "1", 10) || 1) - 1)
|
|
293
|
+
break
|
|
294
|
+
}
|
|
295
|
+
case "A": {
|
|
296
|
+
const n = Number.parseInt(params || "1", 10) || 1
|
|
297
|
+
this.cursorY = Math.max(0, this.cursorY - n)
|
|
298
|
+
break
|
|
299
|
+
}
|
|
300
|
+
case "B": {
|
|
301
|
+
const n = Number.parseInt(params || "1", 10) || 1
|
|
302
|
+
this.cursorY = Math.min(this.height - 1, this.cursorY + n)
|
|
303
|
+
break
|
|
304
|
+
}
|
|
305
|
+
case "C": {
|
|
306
|
+
const n = Number.parseInt(params || "1", 10) || 1
|
|
307
|
+
this.cursorX = Math.min(this.width - 1, this.cursorX + n)
|
|
308
|
+
break
|
|
309
|
+
}
|
|
310
|
+
case "D": {
|
|
311
|
+
const n = Number.parseInt(params || "1", 10) || 1
|
|
312
|
+
this.cursorX = Math.max(0, this.cursorX - n)
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
case "K": {
|
|
316
|
+
const mode = Number.parseInt(params || "0", 10)
|
|
317
|
+
// Erase in Line: fills cleared cells with current bg (per ECMA-48)
|
|
318
|
+
if (mode === 0) {
|
|
319
|
+
for (let x = this.cursorX; x < this.width; x++) {
|
|
320
|
+
this.grid[this.cursorY]![x] = " "
|
|
321
|
+
this.wideMarker[this.cursorY]![x] = false
|
|
322
|
+
this.styles[this.cursorY]![x] = {
|
|
323
|
+
...createDefaultVTermStyle(),
|
|
324
|
+
bg: this.sgr.bg,
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} else if (mode === 1) {
|
|
328
|
+
for (let x = 0; x <= this.cursorX; x++) {
|
|
329
|
+
this.grid[this.cursorY]![x] = " "
|
|
330
|
+
this.wideMarker[this.cursorY]![x] = false
|
|
331
|
+
this.styles[this.cursorY]![x] = {
|
|
332
|
+
...createDefaultVTermStyle(),
|
|
333
|
+
bg: this.sgr.bg,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} else if (mode === 2) {
|
|
337
|
+
for (let x = 0; x < this.width; x++) {
|
|
338
|
+
this.grid[this.cursorY]![x] = " "
|
|
339
|
+
this.wideMarker[this.cursorY]![x] = false
|
|
340
|
+
this.styles[this.cursorY]![x] = {
|
|
341
|
+
...createDefaultVTermStyle(),
|
|
342
|
+
bg: this.sgr.bg,
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
break
|
|
347
|
+
}
|
|
348
|
+
case "m":
|
|
349
|
+
// SGR (Select Graphic Rendition) — apply style changes
|
|
350
|
+
this.applySgr(params)
|
|
351
|
+
break
|
|
352
|
+
case "l":
|
|
353
|
+
case "h":
|
|
354
|
+
// Private modes — ignore for replay
|
|
355
|
+
break
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Apply SGR parameters to the current style state.
|
|
361
|
+
* Parses the semicolon-separated parameter string and updates this.sgr.
|
|
362
|
+
*/
|
|
363
|
+
private applySgr(params: string): void {
|
|
364
|
+
if (params === "" || params === "0") {
|
|
365
|
+
// Reset all attributes
|
|
366
|
+
Object.assign(this.sgr, createDefaultVTermStyle())
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const parts = params.split(";")
|
|
371
|
+
let i = 0
|
|
372
|
+
while (i < parts.length) {
|
|
373
|
+
const code = parts[i]!
|
|
374
|
+
// Handle subparameters (e.g., "4:3" for curly underline)
|
|
375
|
+
const colonIdx = code.indexOf(":")
|
|
376
|
+
if (colonIdx >= 0) {
|
|
377
|
+
const mainCode = Number.parseInt(code.substring(0, colonIdx), 10)
|
|
378
|
+
if (mainCode === 4) {
|
|
379
|
+
const sub = Number.parseInt(code.substring(colonIdx + 1), 10)
|
|
380
|
+
this.sgr.underline = sub > 0
|
|
381
|
+
}
|
|
382
|
+
i++
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const n = Number.parseInt(code, 10)
|
|
387
|
+
if (n === 0) {
|
|
388
|
+
Object.assign(this.sgr, createDefaultVTermStyle())
|
|
389
|
+
} else if (n === 1) {
|
|
390
|
+
this.sgr.bold = true
|
|
391
|
+
} else if (n === 2) {
|
|
392
|
+
this.sgr.dim = true
|
|
393
|
+
} else if (n === 3) {
|
|
394
|
+
this.sgr.italic = true
|
|
395
|
+
} else if (n === 4) {
|
|
396
|
+
this.sgr.underline = true
|
|
397
|
+
} else if (n === 5 || n === 6) {
|
|
398
|
+
this.sgr.blink = true
|
|
399
|
+
} else if (n === 7) {
|
|
400
|
+
this.sgr.inverse = true
|
|
401
|
+
} else if (n === 8) {
|
|
402
|
+
this.sgr.hidden = true
|
|
403
|
+
} else if (n === 9) {
|
|
404
|
+
this.sgr.strikethrough = true
|
|
405
|
+
} else if (n === 22) {
|
|
406
|
+
this.sgr.bold = false
|
|
407
|
+
this.sgr.dim = false
|
|
408
|
+
} else if (n === 23) {
|
|
409
|
+
this.sgr.italic = false
|
|
410
|
+
} else if (n === 24) {
|
|
411
|
+
this.sgr.underline = false
|
|
412
|
+
} else if (n === 25) {
|
|
413
|
+
this.sgr.blink = false
|
|
414
|
+
} else if (n === 27) {
|
|
415
|
+
this.sgr.inverse = false
|
|
416
|
+
} else if (n === 28) {
|
|
417
|
+
this.sgr.hidden = false
|
|
418
|
+
} else if (n === 29) {
|
|
419
|
+
this.sgr.strikethrough = false
|
|
420
|
+
} else if (n >= 30 && n <= 37) {
|
|
421
|
+
// Standard foreground colors (0-7)
|
|
422
|
+
this.sgr.fg = n - 30
|
|
423
|
+
} else if (n === 38) {
|
|
424
|
+
// Extended foreground color
|
|
425
|
+
if (i + 1 < parts.length && parts[i + 1] === "5" && i + 2 < parts.length) {
|
|
426
|
+
// 256-color: \x1b[38;5;Nm
|
|
427
|
+
this.sgr.fg = Number.parseInt(parts[i + 2]!, 10)
|
|
428
|
+
i += 2
|
|
429
|
+
} else if (i + 1 < parts.length && parts[i + 1] === "2" && i + 4 < parts.length) {
|
|
430
|
+
// True color: \x1b[38;2;R;G;Bm
|
|
431
|
+
this.sgr.fg = {
|
|
432
|
+
r: Number.parseInt(parts[i + 2]!, 10),
|
|
433
|
+
g: Number.parseInt(parts[i + 3]!, 10),
|
|
434
|
+
b: Number.parseInt(parts[i + 4]!, 10),
|
|
435
|
+
}
|
|
436
|
+
i += 4
|
|
437
|
+
}
|
|
438
|
+
} else if (n === 39) {
|
|
439
|
+
this.sgr.fg = null
|
|
440
|
+
} else if (n >= 40 && n <= 47) {
|
|
441
|
+
// Standard background colors (0-7)
|
|
442
|
+
this.sgr.bg = n - 40
|
|
443
|
+
} else if (n === 48) {
|
|
444
|
+
// Extended background color
|
|
445
|
+
if (i + 1 < parts.length && parts[i + 1] === "5" && i + 2 < parts.length) {
|
|
446
|
+
// 256-color: \x1b[48;5;Nm
|
|
447
|
+
this.sgr.bg = Number.parseInt(parts[i + 2]!, 10)
|
|
448
|
+
i += 2
|
|
449
|
+
} else if (i + 1 < parts.length && parts[i + 1] === "2" && i + 4 < parts.length) {
|
|
450
|
+
// True color: \x1b[48;2;R;G;Bm
|
|
451
|
+
this.sgr.bg = {
|
|
452
|
+
r: Number.parseInt(parts[i + 2]!, 10),
|
|
453
|
+
g: Number.parseInt(parts[i + 3]!, 10),
|
|
454
|
+
b: Number.parseInt(parts[i + 4]!, 10),
|
|
455
|
+
}
|
|
456
|
+
i += 4
|
|
457
|
+
}
|
|
458
|
+
} else if (n === 49) {
|
|
459
|
+
this.sgr.bg = null
|
|
460
|
+
} else if (n >= 90 && n <= 97) {
|
|
461
|
+
// Bright foreground colors (8-15)
|
|
462
|
+
this.sgr.fg = n - 90 + 8
|
|
463
|
+
} else if (n >= 100 && n <= 107) {
|
|
464
|
+
// Bright background colors (8-15)
|
|
465
|
+
this.sgr.bg = n - 100 + 8
|
|
466
|
+
}
|
|
467
|
+
// 58/59 (underline color) not tracked in diagnostic comparison
|
|
468
|
+
i++
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Get the character at a position.
|
|
474
|
+
*/
|
|
475
|
+
getChar(x: number, y: number): string {
|
|
476
|
+
if (this.wideMarker[y]?.[x]) return ""
|
|
477
|
+
return this.grid[y]?.[x] ?? " "
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Get the style at a position.
|
|
482
|
+
*/
|
|
483
|
+
getStyle(x: number, y: number): VTermStyle {
|
|
484
|
+
return this.styles[y]?.[x] ?? createDefaultVTermStyle()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Compare with a TerminalBuffer and return character mismatches.
|
|
489
|
+
*/
|
|
490
|
+
compareToBuffer(buffer: TerminalBuffer): ReplayMismatch[] {
|
|
491
|
+
const mismatches: ReplayMismatch[] = []
|
|
492
|
+
for (let y = 0; y < Math.min(this.height, buffer.height); y++) {
|
|
493
|
+
for (let x = 0; x < Math.min(this.width, buffer.width); x++) {
|
|
494
|
+
if (buffer.isCellContinuation(x, y)) continue
|
|
495
|
+
|
|
496
|
+
const expected = buffer.getCellChar(x, y)
|
|
497
|
+
const actual = this.getChar(x, y)
|
|
498
|
+
if (expected !== actual) {
|
|
499
|
+
mismatches.push({ x, y, expected, actual })
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return mismatches
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Compare styles with a TerminalBuffer and return style mismatches.
|
|
508
|
+
* Checks fg, bg, bold, dim, italic, underline, blink, inverse, hidden, strikethrough.
|
|
509
|
+
*/
|
|
510
|
+
compareStylesToBuffer(buffer: TerminalBuffer): StyleMismatch[] {
|
|
511
|
+
const mismatches: StyleMismatch[] = []
|
|
512
|
+
for (let y = 0; y < Math.min(this.height, buffer.height); y++) {
|
|
513
|
+
for (let x = 0; x < Math.min(this.width, buffer.width); x++) {
|
|
514
|
+
if (buffer.isCellContinuation(x, y)) continue
|
|
515
|
+
|
|
516
|
+
const cell = buffer.getCell(x, y)
|
|
517
|
+
const actual = this.getStyle(x, y)
|
|
518
|
+
const diffs: string[] = []
|
|
519
|
+
|
|
520
|
+
// Compare foreground color
|
|
521
|
+
if (!colorEquals(actual.fg as Color, cell.fg)) {
|
|
522
|
+
diffs.push(`fg: ${formatVTermColor(actual.fg)} vs ${formatVTermColor(cell.fg)}`)
|
|
523
|
+
}
|
|
524
|
+
// Compare background color
|
|
525
|
+
if (!colorEquals(actual.bg as Color, cell.bg)) {
|
|
526
|
+
diffs.push(`bg: ${formatVTermColor(actual.bg)} vs ${formatVTermColor(cell.bg)}`)
|
|
527
|
+
}
|
|
528
|
+
// Compare text attributes
|
|
529
|
+
if (actual.bold !== !!cell.attrs.bold) {
|
|
530
|
+
diffs.push(`bold: ${actual.bold} vs ${!!cell.attrs.bold}`)
|
|
531
|
+
}
|
|
532
|
+
if (actual.dim !== !!cell.attrs.dim) {
|
|
533
|
+
diffs.push(`dim: ${actual.dim} vs ${!!cell.attrs.dim}`)
|
|
534
|
+
}
|
|
535
|
+
if (actual.italic !== !!cell.attrs.italic) {
|
|
536
|
+
diffs.push(`italic: ${actual.italic} vs ${!!cell.attrs.italic}`)
|
|
537
|
+
}
|
|
538
|
+
// Underline: buffer can have underline or underlineStyle
|
|
539
|
+
const expectedUnderline = !!cell.attrs.underline || !!cell.attrs.underlineStyle
|
|
540
|
+
if (actual.underline !== expectedUnderline) {
|
|
541
|
+
diffs.push(`underline: ${actual.underline} vs ${expectedUnderline}`)
|
|
542
|
+
}
|
|
543
|
+
if (actual.blink !== !!cell.attrs.blink) {
|
|
544
|
+
diffs.push(`blink: ${actual.blink} vs ${!!cell.attrs.blink}`)
|
|
545
|
+
}
|
|
546
|
+
if (actual.inverse !== !!cell.attrs.inverse) {
|
|
547
|
+
diffs.push(`inverse: ${actual.inverse} vs ${!!cell.attrs.inverse}`)
|
|
548
|
+
}
|
|
549
|
+
if (actual.hidden !== !!cell.attrs.hidden) {
|
|
550
|
+
diffs.push(`hidden: ${actual.hidden} vs ${!!cell.attrs.hidden}`)
|
|
551
|
+
}
|
|
552
|
+
if (actual.strikethrough !== !!cell.attrs.strikethrough) {
|
|
553
|
+
diffs.push(`strikethrough: ${actual.strikethrough} vs ${!!cell.attrs.strikethrough}`)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (diffs.length > 0) {
|
|
557
|
+
mismatches.push({ x, y, char: cell.char, diffs })
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return mismatches
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Create a default VTermStyle with all attributes reset to defaults.
|
|
567
|
+
*/
|
|
568
|
+
function createDefaultVTermStyle(): VTermStyle {
|
|
569
|
+
return {
|
|
570
|
+
fg: null,
|
|
571
|
+
bg: null,
|
|
572
|
+
bold: false,
|
|
573
|
+
dim: false,
|
|
574
|
+
italic: false,
|
|
575
|
+
underline: false,
|
|
576
|
+
blink: false,
|
|
577
|
+
inverse: false,
|
|
578
|
+
hidden: false,
|
|
579
|
+
strikethrough: false,
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Format a color value for diagnostic display.
|
|
585
|
+
*/
|
|
586
|
+
function formatVTermColor(c: number | { r: number; g: number; b: number } | null): string {
|
|
587
|
+
if (c === null) return "default"
|
|
588
|
+
if (typeof c === "number") return `${c}`
|
|
589
|
+
return `rgb(${c.r},${c.g},${c.b})`
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// =============================================================================
|
|
593
|
+
// Constants
|
|
594
|
+
// =============================================================================
|
|
595
|
+
|
|
596
|
+
/** Commands that should preserve buffer content (only cursor position changes) */
|
|
597
|
+
const CURSOR_COMMANDS = new Set([
|
|
598
|
+
// Full names
|
|
599
|
+
"cursor_up",
|
|
600
|
+
"cursor_down",
|
|
601
|
+
"cursor_left",
|
|
602
|
+
"cursor_right",
|
|
603
|
+
// Short names
|
|
604
|
+
"up",
|
|
605
|
+
"down",
|
|
606
|
+
"left",
|
|
607
|
+
"right",
|
|
608
|
+
])
|
|
609
|
+
|
|
610
|
+
// =============================================================================
|
|
611
|
+
// Helper Functions
|
|
612
|
+
// =============================================================================
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Parse SILVERY_STABILITY_SKIP_LINES environment variable.
|
|
616
|
+
* Format: comma-separated integers, e.g., "0,-1"
|
|
617
|
+
*/
|
|
618
|
+
function parseSkipLines(env?: string): number[] {
|
|
619
|
+
if (!env) return []
|
|
620
|
+
return env
|
|
621
|
+
.split(",")
|
|
622
|
+
.map((s) => Number.parseInt(s.trim(), 10))
|
|
623
|
+
.filter((n) => !isNaN(n))
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Compare text content before and after a command.
|
|
628
|
+
* Returns the first mismatch found, or null if content matches.
|
|
629
|
+
*
|
|
630
|
+
* @param before - Text before command execution
|
|
631
|
+
* @param after - Text after command execution
|
|
632
|
+
* @param skipLines - Line indices to skip (supports negative indices from end)
|
|
633
|
+
*/
|
|
634
|
+
function compareText(before: string, after: string, skipLines: number[]): TextMismatch | null {
|
|
635
|
+
const beforeLines = before.split("\n")
|
|
636
|
+
const afterLines = after.split("\n")
|
|
637
|
+
const maxLines = Math.max(beforeLines.length, afterLines.length)
|
|
638
|
+
|
|
639
|
+
// Build set of lines to skip, resolving negative indices
|
|
640
|
+
const skipSet = new Set<number>()
|
|
641
|
+
for (const line of skipLines) {
|
|
642
|
+
if (line >= 0) {
|
|
643
|
+
skipSet.add(line)
|
|
644
|
+
} else {
|
|
645
|
+
// Negative index: -1 = last line, -2 = second to last, etc.
|
|
646
|
+
skipSet.add(maxLines + line)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
for (let i = 0; i < maxLines; i++) {
|
|
651
|
+
if (skipSet.has(i)) continue
|
|
652
|
+
const b = beforeLines[i] ?? ""
|
|
653
|
+
const a = afterLines[i] ?? ""
|
|
654
|
+
if (b !== a) {
|
|
655
|
+
return { line: i, before: b, after: a }
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return null
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// =============================================================================
|
|
662
|
+
// Layout Invariant Checks
|
|
663
|
+
// =============================================================================
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Check layout tree integrity. Returns violation messages, or empty array if valid.
|
|
667
|
+
*
|
|
668
|
+
* Checks:
|
|
669
|
+
* - All rect dimensions are finite and non-negative (width >= 0, height >= 0)
|
|
670
|
+
* - All positions are finite (x, y are valid numbers)
|
|
671
|
+
* - No NaN values in computed layout
|
|
672
|
+
* - Children don't overflow parent bounds (1px tolerance for rounding)
|
|
673
|
+
* - Skips overflow check for nodes with overflow:hidden/scroll (they intentionally clip)
|
|
674
|
+
*/
|
|
675
|
+
export function checkLayoutInvariants(node: TeaNode): string[] {
|
|
676
|
+
const violations: string[] = []
|
|
677
|
+
walkLayout(node, null, violations)
|
|
678
|
+
return violations
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function walkLayout(
|
|
682
|
+
node: TeaNode,
|
|
683
|
+
parentRect: {
|
|
684
|
+
x: number
|
|
685
|
+
y: number
|
|
686
|
+
width: number
|
|
687
|
+
height: number
|
|
688
|
+
clipped: boolean
|
|
689
|
+
} | null,
|
|
690
|
+
violations: string[],
|
|
691
|
+
): void {
|
|
692
|
+
const rect = node.contentRect
|
|
693
|
+
if (!rect) return // No layout computed yet — skip
|
|
694
|
+
|
|
695
|
+
const id = (node.props as BoxProps).id ?? node.type
|
|
696
|
+
|
|
697
|
+
// Check finite and non-negative dimensions
|
|
698
|
+
if (!Number.isFinite(rect.width) || rect.width < 0) {
|
|
699
|
+
violations.push(`${id}: invalid width ${rect.width}`)
|
|
700
|
+
}
|
|
701
|
+
if (!Number.isFinite(rect.height) || rect.height < 0) {
|
|
702
|
+
violations.push(`${id}: invalid height ${rect.height}`)
|
|
703
|
+
}
|
|
704
|
+
if (!Number.isFinite(rect.x)) {
|
|
705
|
+
violations.push(`${id}: invalid x ${rect.x}`)
|
|
706
|
+
}
|
|
707
|
+
if (!Number.isFinite(rect.y)) {
|
|
708
|
+
violations.push(`${id}: invalid y ${rect.y}`)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Check children don't overflow parent (with 1px tolerance)
|
|
712
|
+
if (parentRect && !parentRect.clipped) {
|
|
713
|
+
const TOLERANCE = 1
|
|
714
|
+
if (rect.x + rect.width > parentRect.x + parentRect.width + TOLERANCE) {
|
|
715
|
+
violations.push(`${id}: overflows parent right (${rect.x + rect.width} > ${parentRect.x + parentRect.width})`)
|
|
716
|
+
}
|
|
717
|
+
if (rect.y + rect.height > parentRect.y + parentRect.height + TOLERANCE) {
|
|
718
|
+
violations.push(`${id}: overflows parent bottom (${rect.y + rect.height} > ${parentRect.y + parentRect.height})`)
|
|
719
|
+
}
|
|
720
|
+
if (rect.x < parentRect.x - TOLERANCE) {
|
|
721
|
+
violations.push(`${id}: overflows parent left (${rect.x} < ${parentRect.x})`)
|
|
722
|
+
}
|
|
723
|
+
if (rect.y < parentRect.y - TOLERANCE) {
|
|
724
|
+
violations.push(`${id}: overflows parent top (${rect.y} < ${parentRect.y})`)
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Determine if this node clips its children
|
|
729
|
+
const overflow = (node.props as BoxProps).overflow
|
|
730
|
+
const clipped = overflow === "hidden" || overflow === "scroll"
|
|
731
|
+
|
|
732
|
+
const childParentRect = {
|
|
733
|
+
x: rect.x,
|
|
734
|
+
y: rect.y,
|
|
735
|
+
width: rect.width,
|
|
736
|
+
height: rect.height,
|
|
737
|
+
clipped,
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
for (const child of node.children) {
|
|
741
|
+
walkLayout(child, childParentRect, violations)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// =============================================================================
|
|
746
|
+
// Plugin Implementation
|
|
747
|
+
// =============================================================================
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Add diagnostic checking to an app with commands.
|
|
751
|
+
*
|
|
752
|
+
* Wraps the `cmd` proxy to intercept all command executions and run checks:
|
|
753
|
+
* - **All commands**: Check that incremental render matches fresh render
|
|
754
|
+
* - **Cursor commands**: Also check that buffer content didn't change
|
|
755
|
+
*
|
|
756
|
+
* **All checks are enabled by default** when you call this function.
|
|
757
|
+
* The principle: if you wrapped with withDiagnostics(), you want diagnostics.
|
|
758
|
+
*
|
|
759
|
+
* @param app - App with command system (from withCommands)
|
|
760
|
+
* @param options - Diagnostic check configuration (all enabled by default)
|
|
761
|
+
* @returns App with wrapped cmd that runs diagnostic checks
|
|
762
|
+
*/
|
|
763
|
+
export function withDiagnostics<T extends AppWithCommands>(app: T, options: DiagnosticOptions = {}): T {
|
|
764
|
+
// All checks enabled by default when plugin is used
|
|
765
|
+
const {
|
|
766
|
+
checkIncremental = true,
|
|
767
|
+
checkStability = true,
|
|
768
|
+
checkReplay = true,
|
|
769
|
+
checkLayout = true,
|
|
770
|
+
skipLines = parseSkipLines(process.env.SILVERY_STABILITY_SKIP_LINES),
|
|
771
|
+
captureOnFailure = false,
|
|
772
|
+
screenshotDir = "/tmp/silvery-diagnostics",
|
|
773
|
+
} = options
|
|
774
|
+
|
|
775
|
+
// If all checks are explicitly disabled, return app unchanged
|
|
776
|
+
if (!checkIncremental && !checkStability && !checkReplay && !checkLayout) return app
|
|
777
|
+
|
|
778
|
+
/** Capture screenshot on diagnostic failure (best-effort, never masks original error) */
|
|
779
|
+
async function captureFailureScreenshot(commandId: string, checkType: string): Promise<string | null> {
|
|
780
|
+
if (!captureOnFailure) return null
|
|
781
|
+
try {
|
|
782
|
+
await mkdir(screenshotDir, { recursive: true })
|
|
783
|
+
const filename = `fail-${commandId}-${checkType}.png`
|
|
784
|
+
const filepath = join(screenshotDir, filename)
|
|
785
|
+
await app.screenshot(filepath)
|
|
786
|
+
return filepath
|
|
787
|
+
} catch {
|
|
788
|
+
return null
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Wrap the cmd proxy
|
|
793
|
+
const wrappedCmd = new Proxy(app.cmd, {
|
|
794
|
+
get(target, prop: string | symbol): unknown {
|
|
795
|
+
// Handle symbol access (for JS internals)
|
|
796
|
+
if (typeof prop === "symbol") return Reflect.get(target, prop)
|
|
797
|
+
|
|
798
|
+
const original = Reflect.get(target, prop)
|
|
799
|
+
|
|
800
|
+
// Pass through non-function properties and special methods
|
|
801
|
+
if (typeof original !== "function") return original
|
|
802
|
+
if (prop === "all" || prop === "describe") return original
|
|
803
|
+
|
|
804
|
+
// Wrap command execution
|
|
805
|
+
const command = original as Command
|
|
806
|
+
const wrapped = async () => {
|
|
807
|
+
// Capture state before command
|
|
808
|
+
const beforeText = app.text
|
|
809
|
+
const beforeBuffer = checkReplay ? app.lastBuffer() : null
|
|
810
|
+
|
|
811
|
+
// Execute the original command
|
|
812
|
+
await command()
|
|
813
|
+
|
|
814
|
+
// Check 1: Incremental vs fresh render
|
|
815
|
+
if (checkIncremental) {
|
|
816
|
+
const incremental = app.lastBuffer()
|
|
817
|
+
// freshRender() may throw if not available (non-test renderer)
|
|
818
|
+
try {
|
|
819
|
+
const fresh = app.freshRender()
|
|
820
|
+
if (incremental && fresh) {
|
|
821
|
+
const mismatch = compareBuffers(incremental, fresh)
|
|
822
|
+
if (mismatch) {
|
|
823
|
+
// Include full buffer text for debugging
|
|
824
|
+
const incrementalText = app.text
|
|
825
|
+
const freshText = fresh
|
|
826
|
+
? Array.from({ length: fresh.height }, (_, y) =>
|
|
827
|
+
Array.from({ length: fresh.width }, (_, x) => fresh.getCellChar(x, y)).join(""),
|
|
828
|
+
).join("\n")
|
|
829
|
+
: "(no fresh buffer)"
|
|
830
|
+
const screenshotPath = await captureFailureScreenshot(command.id, "incremental")
|
|
831
|
+
throw new Error(
|
|
832
|
+
`SILVERY_DIAGNOSTIC: Incremental/fresh mismatch after ${command.id}\n` +
|
|
833
|
+
formatMismatch(mismatch, {
|
|
834
|
+
key: command.id,
|
|
835
|
+
incrementalText,
|
|
836
|
+
freshText,
|
|
837
|
+
}) +
|
|
838
|
+
(screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""),
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
} catch (e) {
|
|
843
|
+
// If freshRender isn't available, skip the check
|
|
844
|
+
if (!(e instanceof Error) || !e.message.includes("only available in test renderer")) {
|
|
845
|
+
throw e
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Check 2: Content stability for cursor commands
|
|
851
|
+
if (checkStability && CURSOR_COMMANDS.has(command.id)) {
|
|
852
|
+
const afterText = app.text
|
|
853
|
+
const mismatch = compareText(beforeText, afterText, skipLines)
|
|
854
|
+
if (mismatch) {
|
|
855
|
+
const screenshotPath = await captureFailureScreenshot(command.id, "stability")
|
|
856
|
+
throw new Error(
|
|
857
|
+
`SILVERY_DIAGNOSTIC: Content changed after cursor move ${command.id}\n` +
|
|
858
|
+
` Line ${mismatch.line}: "${mismatch.before}" → "${mismatch.after}"` +
|
|
859
|
+
(screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""),
|
|
860
|
+
)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Check 3: ANSI replay produces correct result
|
|
865
|
+
if (checkReplay && beforeBuffer) {
|
|
866
|
+
const afterBuffer = app.lastBuffer()
|
|
867
|
+
if (afterBuffer) {
|
|
868
|
+
// Get the ANSI diff that would be sent to terminal
|
|
869
|
+
const ansiDiff = outputPhase(beforeBuffer, afterBuffer)
|
|
870
|
+
|
|
871
|
+
// Create virtual terminal initialized with previous state
|
|
872
|
+
const vterm = new VirtualTerminal(afterBuffer.width, afterBuffer.height)
|
|
873
|
+
vterm.loadFromBuffer(beforeBuffer)
|
|
874
|
+
|
|
875
|
+
// Apply the ANSI diff
|
|
876
|
+
vterm.applyAnsi(ansiDiff)
|
|
877
|
+
|
|
878
|
+
// Compare character content
|
|
879
|
+
const mismatches = vterm.compareToBuffer(afterBuffer)
|
|
880
|
+
if (mismatches.length > 0) {
|
|
881
|
+
const first5 = mismatches.slice(0, 5)
|
|
882
|
+
const details = first5
|
|
883
|
+
.map((m) => ` (${m.x},${m.y}): expected="${m.expected}" actual="${m.actual}"`)
|
|
884
|
+
.join("\n")
|
|
885
|
+
const screenshotPath = await captureFailureScreenshot(command.id, "replay")
|
|
886
|
+
throw new Error(
|
|
887
|
+
`SILVERY_DIAGNOSTIC: ANSI replay mismatch after ${command.id}\n` +
|
|
888
|
+
` ${mismatches.length} cells differ:\n${details}` +
|
|
889
|
+
(mismatches.length > 5 ? `\n ... and ${mismatches.length - 5} more` : "") +
|
|
890
|
+
(screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""),
|
|
891
|
+
)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Compare SGR styles (fg, bg, bold, italic, underline, etc.)
|
|
895
|
+
const styleMismatches = vterm.compareStylesToBuffer(afterBuffer)
|
|
896
|
+
if (styleMismatches.length > 0) {
|
|
897
|
+
const first5 = styleMismatches.slice(0, 5)
|
|
898
|
+
const details = first5.map((m) => ` (${m.x},${m.y}) char="${m.char}": ${m.diffs.join(", ")}`).join("\n")
|
|
899
|
+
const screenshotPath = await captureFailureScreenshot(command.id, "replay-style")
|
|
900
|
+
throw new Error(
|
|
901
|
+
`SILVERY_DIAGNOSTIC: ANSI replay style mismatch after ${command.id}\n` +
|
|
902
|
+
` ${styleMismatches.length} cells have style differences:\n${details}` +
|
|
903
|
+
(styleMismatches.length > 5 ? `\n ... and ${styleMismatches.length - 5} more` : "") +
|
|
904
|
+
(screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""),
|
|
905
|
+
)
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Check 4: Layout tree integrity
|
|
911
|
+
if (checkLayout) {
|
|
912
|
+
const root = app.getContainer()
|
|
913
|
+
const violations = checkLayoutInvariants(root)
|
|
914
|
+
if (violations.length > 0) {
|
|
915
|
+
const details = violations
|
|
916
|
+
.slice(0, 10)
|
|
917
|
+
.map((v) => ` ${v}`)
|
|
918
|
+
.join("\n")
|
|
919
|
+
const screenshotPath = await captureFailureScreenshot(command.id, "layout")
|
|
920
|
+
throw new Error(
|
|
921
|
+
`SILVERY_DIAGNOSTIC: Layout invariant violation after ${command.id}\n` +
|
|
922
|
+
` ${violations.length} violation(s):\n${details}` +
|
|
923
|
+
(violations.length > 10 ? `\n ... and ${violations.length - 10} more` : "") +
|
|
924
|
+
(screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""),
|
|
925
|
+
)
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Copy metadata from original command
|
|
931
|
+
Object.defineProperties(wrapped, {
|
|
932
|
+
id: { value: command.id, enumerable: true },
|
|
933
|
+
name: { value: command.name, enumerable: true },
|
|
934
|
+
help: { value: command.help, enumerable: true },
|
|
935
|
+
keys: { value: command.keys, enumerable: true },
|
|
936
|
+
})
|
|
937
|
+
|
|
938
|
+
return wrapped
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
has(target, prop): boolean {
|
|
942
|
+
return Reflect.has(target, prop)
|
|
943
|
+
},
|
|
944
|
+
|
|
945
|
+
ownKeys(target): (string | symbol)[] {
|
|
946
|
+
return Reflect.ownKeys(target)
|
|
947
|
+
},
|
|
948
|
+
|
|
949
|
+
getOwnPropertyDescriptor(target, prop): PropertyDescriptor | undefined {
|
|
950
|
+
return Reflect.getOwnPropertyDescriptor(target, prop)
|
|
951
|
+
},
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
return { ...app, cmd: wrappedCmd as Cmd } as T
|
|
955
|
+
}
|