oxedyne/daimond/www/js/terminal.test.mjs
3.8 KiB, 1 run
created by r2519314175:1520, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* ============================================================ |
| 2 | Test — the terminal parser survives a control string split |
| 3 | between two `write` calls. |
| 4 | ------------------------------------------------------------ |
| 5 | Drives the REAL www/js/terminal.js screen model. The String |
| 6 | Terminator that ends an OSC or DCS sequence is two bytes, |
| 7 | ESC then '\' (0x1b 0x5c). A pty read splits a burst of output |
| 8 | anywhere, so those two bytes routinely arrive in DIFFERENT |
| 9 | `write` calls -- the comment at the top of the parser says a |
| 10 | split sequence is "the normal case, not the odd one". |
| 11 | |
| 12 | The bug this proves against: the ST was recognised by looking |
| 13 | BACK one byte in the current chunk (`s.charCodeAt(i - 1)`), |
| 14 | which is NaN when the ESC ended the previous chunk. The |
| 15 | terminator was then missed and the parser went on swallowing |
| 16 | the rest of the session into the string payload, drawing |
| 17 | nothing -- a live terminal that looks hung, which is exactly |
| 18 | what an interactive `ssh` produced (its remote shell and tmux |
| 19 | emit OSC/DCS strings, and ssh delivers the remote's output in |
| 20 | many small, arbitrarily split writes). |
| 21 | |
| 22 | Run: node www/js/terminal.test.mjs |
| 23 | ============================================================ */ |
| 24 | import { readFileSync } from 'node:fs'; |
| 25 | import { fileURLToPath } from 'node:url'; |
| 26 | import { dirname, join } from 'node:path'; |
| 27 | |
| 28 | const HERE = dirname(fileURLToPath(import.meta.url)); |
| 29 | |
| 30 | // The file is a browser IIFE that assigns window.DaimondTerminal. |
| 31 | // It touches `window` only inside functions, so a bare object is |
| 32 | // enough to load it; the screen model needs no DOM at all. |
| 33 | globalThis.window = globalThis.window || {}; |
| 34 | (0, eval)(readFileSync(join(HERE, 'terminal.js'), 'utf8')); |
| 35 | const T = globalThis.window.DaimondTerminal; |
| 36 | |
| 37 | const enc = new TextEncoder(); |
| 38 | let failures = 0; |
| 39 | function check(name, cond) { |
| 40 | if (cond) { console.log(' ok ' + name); } |
| 41 | else { console.log(' FAIL ' + name); failures++; } |
| 42 | } |
| 43 | |
| 44 | // The first row's text, trailing blanks trimmed. |
| 45 | function row0(chunks) { |
| 46 | const S = T.screen(40, 4, {}); |
| 47 | for (const c of chunks) S.write(c); |
| 48 | return S.lineText(S.absOfRow(0)).replace(/\s+$/, ''); |
| 49 | } |
| 50 | |
| 51 | console.log('String Terminator handling'); |
| 52 | |
| 53 | // The sequences that already worked, kept so a fix cannot regress them. |
| 54 | check('OSC ended by ST in one write draws the text after it', |
| 55 | row0(['\x1b]0;title\x1b\\AFTER']) === 'AFTER'); |
| 56 | check('OSC ended by BEL in one write draws the text after it', |
| 57 | row0(['\x1b]0;title\x07AFTER']) === 'AFTER'); |
| 58 | |
| 59 | // The bug: the ST split across two writes -- ESC ends the first, |
| 60 | // '\' begins the second. Everything after it must still draw. |
| 61 | check('ST split across writes still terminates the string', |
| 62 | row0(['\x1b]0;title\x1b', '\\AFTER']) === 'AFTER'); |
| 63 | check('ST split across writes, at the byte level (Uint8Array)', |
| 64 | row0([enc.encode('\x1b]0;title\x1b'), enc.encode('\\AFTER')]) === 'AFTER'); |
| 65 | |
| 66 | // A semantic-prompt marker (OSC 133) split the same way: the ground |
| 67 | // text before it and the shell prompt after it must both survive. |
| 68 | check('a split OSC-133 prompt marker does not swallow the prompt', |
| 69 | row0(['pre\x1b]133;A\x1b', '\\$ hi']) === 'pre$ hi'); |
| 70 | |
| 71 | // A DCS string (ESC P ... ST) split the same way: the payload is |
| 72 | // discarded, but the text after the ST draws. |
| 73 | check('DCS payload split at its ST discards cleanly and keeps drawing', |
| 74 | row0(['\x1bPq;junk\x1b', '\\AFTER']) === 'AFTER'); |
| 75 | |
| 76 | // Preserved oddities: an ESC inside a string that is NOT part of an ST |
| 77 | // is dropped and the string continues to its real terminator, exactly |
| 78 | // as before the fix. |
| 79 | check('ESC not followed by \\ inside a string is dropped, string continues', |
| 80 | row0(['\x1b]0;a\x1bXb\x07AFTER']) === 'AFTER'); |
| 81 | check('ESC ESC \\ within one write terminates on the real ST', |
| 82 | row0(['\x1b]0;t\x1b\x1b\\AFTER']) === 'AFTER'); |
| 83 | |
| 84 | console.log(failures === 0 ? '\nALL PASS' : ('\n' + failures + ' FAILURE(S)')); |
| 85 | if (failures) process.exitCode = 1; |