-
Notifications
You must be signed in to change notification settings - Fork 115
Expand file tree
/
Copy pathiris-repro-final.test.ts
More file actions
256 lines (224 loc) · 8.79 KB
/
iris-repro-final.test.ts
File metadata and controls
256 lines (224 loc) · 8.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
/**
* Minimal self-contained reproduction of WASM viewport/ring-buffer corruption.
*
* BUG: Writing escape-heavy output (~68 lines with SGR sequences) repeatedly
* to a terminal causes the internal circular buffer to misindex after ~8 reps.
*
* Symptoms:
* 1. getScrollbackLength() drops unexpectedly (e.g., 498 → 269) — the ring
* buffer's row tracking becomes incorrect.
* 2. At certain column widths, getViewport() returns corrupted data where
* content from different lines is horizontally merged into one row.
* 3. Both getViewport() and getLine() return the same wrong data.
*
* The corruption depends on column width (NOT data content):
* - cols=80: OK cols=120: CORRUPT cols=130: CORRUPT
* - cols=140: OK cols=160: scrollback drops but viewport appears OK
* (row merge lands on empty rows)
*
* This is 100% self-contained — no external fixture files needed.
*/
import { describe, expect, test } from 'bun:test';
import { createIsolatedTerminal } from './test-helpers';
import type { Terminal } from './terminal';
const ESC = '\x1b';
/**
* Generate escape-heavy terminal output similar to a color test script.
* Produces ~68 lines with SGR 1/3/4/7, 256-color, and truecolor sequences.
*/
function generateTestOutput(): Uint8Array {
const lines: string[] = [];
// Bold banner with Unicode box-drawing characters
lines.push(`${ESC}[1m${'═'.repeat(80)}${ESC}[0m`);
lines.push('');
// Section 1: 256-color palette blocks (8 rows of 32 colors)
lines.push(`${ESC}[1m── COLORS ──${ESC}[0m`);
for (let row = 0; row < 8; row++) {
let line = '';
for (let i = 0; i < 32; i++) {
const idx = row * 32 + i;
line += `${ESC}[48;5;${idx}m ${ESC}[0m`;
}
lines.push(line);
}
// Section 2: Truecolor gradients (6 rows of 80 colored cells)
lines.push(`${ESC}[1m── GRADIENTS ──${ESC}[0m`);
for (let row = 0; row < 6; row++) {
let line = '';
for (let i = 0; i < 80; i++) {
const r = Math.floor(Math.sin(i * 0.08 + row) * 127 + 128);
const g = Math.floor(Math.sin(i * 0.08 + row + 2) * 127 + 128);
const b = Math.floor(Math.sin(i * 0.08 + row + 4) * 127 + 128);
line += `${ESC}[48;2;${r};${g};${b}m ${ESC}[0m`;
}
lines.push(line);
}
// Section 3: Text attributes
lines.push(`${ESC}[1m── ATTRIBUTES ──${ESC}[0m`);
lines.push(` ${ESC}[1mBold${ESC}[0m ${ESC}[3mItalic${ESC}[0m ${ESC}[4mUnderline${ESC}[0m ${ESC}[7mReverse${ESC}[0m`);
// Section 4: Unicode box drawing
lines.push(`${ESC}[1m── UNICODE ──${ESC}[0m`);
lines.push(' ┌──────────┬──────────┐');
lines.push(' │ Cell A │ Cell B │');
lines.push(' ├──────────┼──────────┤');
lines.push(' │ Cell C │ Cell D │');
lines.push(' └──────────┴──────────┘');
// Sections 5-8: More colored text to reach ~68 lines
for (let section = 0; section < 4; section++) {
lines.push(`${ESC}[1m── SECTION ${section + 5} ──${ESC}[0m`);
for (let row = 0; row < 8; row++) {
let line = ' ';
for (let i = 0; i < 60; i++) {
const idx = (section * 64 + row * 8 + i) % 256;
line += `${ESC}[38;5;${idx}m*${ESC}[0m`;
}
lines.push(line);
}
}
// Final banner
lines.push('');
lines.push('═'.repeat(80));
lines.push(' ✓ Test complete');
lines.push('═'.repeat(80));
lines.push('');
return new TextEncoder().encode(lines.join('\r\n') + '\r\n');
}
function getViewportText(term: Terminal): string[] {
const viewport = term.wasmTerm!.getViewport();
const cols = term.cols;
const rows: string[] = [];
for (let row = 0; row < term.rows; row++) {
let text = '';
for (let col = 0; col < cols; col++) {
const c = viewport[row * cols + col];
if (c.width === 0) continue;
text += c.codepoint > 32 ? String.fromCodePoint(c.codepoint) : ' ';
}
rows.push(text.trimEnd());
}
return rows;
}
describe('WASM ring buffer corruption — self-contained reproduction', () => {
const data = generateTestOutput();
/**
* PRIMARY BUG INDICATOR: scrollbackLength should increase monotonically
* when writing the same data repeatedly. The ring buffer corruption
* causes it to jump backwards.
*/
test('scrollbackLength increases monotonically after repeated writes', async () => {
const term = await createIsolatedTerminal({ cols: 160, rows: 39, scrollback: 10000 });
const container = document.createElement('div');
term.open(container);
const sbLengths: number[] = [];
for (let rep = 0; rep < 12; rep++) {
term.write(data);
term.wasmTerm!.update();
sbLengths.push(term.wasmTerm!.getScrollbackLength());
}
console.log('Scrollback lengths:', sbLengths);
// Find non-monotonic drops
let drops = 0;
for (let i = 1; i < sbLengths.length; i++) {
if (sbLengths[i] < sbLengths[i - 1]) {
drops++;
console.log(`Drop at rep ${i}: ${sbLengths[i-1]} → ${sbLengths[i]} (delta ${sbLengths[i] - sbLengths[i-1]})`);
}
}
// Scrollback should never decrease when writing new data
expect(drops).toBe(0);
term.dispose();
});
/**
* Viewport text should remain stable across repeated writes.
* The old bug caused catastrophic row-merging (many rows corrupted at early reps).
* After the fix, at most 1 row may show a trivial trailing-whitespace diff.
*/
test('viewport text remains stable at cols=130 after repeated writes', async () => {
const term = await createIsolatedTerminal({ cols: 130, rows: 39, scrollback: 10000 });
const container = document.createElement('div');
term.open(container);
let baseline: string[] | null = null;
let maxDiffRows = 0;
for (let rep = 0; rep < 12; rep++) {
term.write(data);
term.wasmTerm!.update();
const text = getViewportText(term);
if (!baseline) {
baseline = text;
} else {
let diffs = 0;
for (let i = 0; i < Math.max(text.length, baseline.length); i++) {
if ((text[i] || '') !== (baseline[i] || '')) {
diffs++;
}
}
if (diffs > maxDiffRows) maxDiffRows = diffs;
}
}
// The old bug caused 10+ rows of corruption at early reps.
// After the fix, at most 1 row may differ (trailing whitespace artifact).
console.log(`Max diff rows across reps: ${maxDiffRows}`);
expect(maxDiffRows).toBeLessThanOrEqual(1);
term.dispose();
});
/**
* getViewport and getLine agree — corruption is in the underlying
* WASM state, not just in one API.
*/
test('getViewport and getLine return identical (corrupted) data', async () => {
const term = await createIsolatedTerminal({ cols: 130, rows: 39, scrollback: 10000 });
const container = document.createElement('div');
term.open(container);
for (let rep = 0; rep < 12; rep++) {
term.write(data);
term.wasmTerm!.update();
}
const vpText = getViewportText(term);
let matches = 0;
for (let row = 0; row < term.rows; row++) {
const line = term.wasmTerm?.getLine(row);
if (!line) continue;
const lnText = line.map(c => String.fromCodePoint(c.codepoint || 32)).join('').trimEnd();
if (vpText[row] === lnText) matches++;
}
console.log(`${matches}/${term.rows} viewport rows match getLine`);
expect(matches).toBe(term.rows);
term.dispose();
});
/**
* Column width affects whether the corruption is visible in viewport text.
* The ring buffer always corrupts, but row merging is only detectable when
* the misaligned rows contain different content.
*/
test('column width sensitivity', async () => {
const results: string[] = [];
for (const cols of [80, 100, 120, 130, 140, 160]) {
const term = await createIsolatedTerminal({ cols, rows: 39, scrollback: 10000 });
const container = document.createElement('div');
term.open(container);
const sbLengths: number[] = [];
let baseline: string[] | null = null;
let vpCorrupt = false;
for (let rep = 0; rep < 12; rep++) {
term.write(data);
term.wasmTerm!.update();
sbLengths.push(term.wasmTerm!.getScrollbackLength());
const text = getViewportText(term);
if (!baseline) { baseline = text; }
else {
for (let i = 0; i < Math.max(text.length, baseline.length); i++) {
if ((text[i] || '') !== (baseline[i] || '')) { vpCorrupt = true; break; }
}
}
}
let sbDrops = 0;
for (let i = 1; i < sbLengths.length; i++) {
if (sbLengths[i] < sbLengths[i - 1]) sbDrops++;
}
const line = `cols=${cols}: scrollback_drops=${sbDrops} viewport_corrupt=${vpCorrupt}`;
results.push(line);
console.log(line);
term.dispose();
}
});
});