|
1 | 1 | #!/usr/bin/env node |
2 | 2 | import fs from "node:fs" |
| 3 | +import assert from "node:assert/strict" |
3 | 4 |
|
4 | | -const [path, mode, expectedRaw] = process.argv.slice(2) |
5 | | -if (!path || !mode) process.exit(2) |
6 | | -const data = fs.existsSync(path) ? fs.readFileSync(path) : Buffer.alloc(0) |
7 | | -const packets = [] |
8 | | -let offset = 0 |
9 | | -while (offset + 5 <= data.length) { |
10 | | - const type = data.readUInt8(offset) |
11 | | - const length = data.readUInt32BE(offset + 1) |
12 | | - if (length > 32 * 1024 * 1024) throw new Error(`oversized frame: ${length}`) |
13 | | - if (offset + 5 + length > data.length) break |
14 | | - packets.push({ type, payload: data.subarray(offset + 5, offset + 5 + length) }) |
15 | | - offset += 5 + length |
| 5 | +const geometry = (rows, columns) => { |
| 6 | + const payload = Buffer.alloc(4) |
| 7 | + payload.writeUInt16BE(rows, 0) |
| 8 | + payload.writeUInt16BE(columns, 2) |
| 9 | + return payload |
16 | 10 | } |
17 | 11 |
|
18 | | -const snapshotIndexes = [] |
19 | | -for (let index = 0; index + 1 < packets.length; index++) { |
20 | | - if (packets[index].type === 10 && packets[index + 1].type === 5) snapshotIndexes.push(index) |
| 12 | +const frame = (type, payload) => { |
| 13 | + const header = Buffer.alloc(5) |
| 14 | + header.writeUInt8(type) |
| 15 | + header.writeUInt32BE(payload.length, 1) |
| 16 | + return Buffer.concat([header, payload]) |
21 | 17 | } |
22 | 18 |
|
23 | | -if (mode === "snapshots") { |
24 | | - const expected = Number(expectedRaw) |
25 | | - process.exit(snapshotIndexes.length >= expected ? 0 : 1) |
26 | | -} |
27 | | -if (mode !== "final") process.exit(2) |
28 | | -if (offset !== data.length) throw new Error("truncated trailing frame") |
29 | | -if (packets.length < 5) throw new Error("too few frames") |
30 | | -if (packets[0].type !== 10 || packets[1].type !== 5) { |
31 | | - throw new Error("initial stream does not begin with GEOMETRY, SCREEN") |
32 | | -} |
33 | | -if (snapshotIndexes.length !== 2) { |
34 | | - throw new Error(`expected two snapshots, got ${snapshotIndexes.length}`) |
35 | | -} |
36 | | -for (const index of snapshotIndexes) { |
37 | | - const geometry = packets[index].payload |
38 | | - if (geometry.length !== 4 || geometry.readUInt16BE(0) === 0 || geometry.readUInt16BE(2) === 0) { |
39 | | - throw new Error("invalid geometry payload") |
| 19 | +const decode = (data, complete) => { |
| 20 | + const packets = [] |
| 21 | + let offset = 0 |
| 22 | + while (offset + 5 <= data.length) { |
| 23 | + const type = data.readUInt8(offset) |
| 24 | + const length = data.readUInt32BE(offset + 1) |
| 25 | + if (length > 32 * 1024 * 1024) throw new Error(`oversized frame: ${length}`) |
| 26 | + if (offset + 5 + length > data.length) break |
| 27 | + packets.push({ type, payload: data.subarray(offset + 5, offset + 5 + length) }) |
| 28 | + offset += 5 + length |
40 | 29 | } |
| 30 | + if (complete && offset !== data.length) throw new Error("truncated trailing frame") |
| 31 | + return packets |
41 | 32 | } |
42 | | -const initial = packets[snapshotIndexes[0] + 1].payload |
43 | | -const reconnected = packets[snapshotIndexes[1] + 1].payload |
44 | | -if (!initial.includes(Buffer.from("INITIAL_COLOR_61e8")) || !initial.includes(Buffer.from("\x1b"))) { |
45 | | - throw new Error("initial screen lost colored terminal state") |
| 33 | + |
| 34 | +const snapshotIndexes = (packets) => { |
| 35 | + const indexes = [] |
| 36 | + for (let index = 0; index + 1 < packets.length; index++) { |
| 37 | + if (packets[index].type === 10 && packets[index + 1].type === 5) indexes.push(index) |
| 38 | + } |
| 39 | + return indexes |
46 | 40 | } |
47 | | -if (!reconnected.includes(Buffer.from("AFTER_DROP_61e8"))) { |
48 | | - throw new Error("reconnect screen is not the current terminal state") |
| 41 | + |
| 42 | +const parseGeometries = (raw) => raw.split(",").map((entry) => { |
| 43 | + const match = /^(\d+)x(\d+)$/.exec(entry) |
| 44 | + if (!match) throw new Error(`invalid expected geometry: ${entry}`) |
| 45 | + return { rows: Number(match[1]), columns: Number(match[2]) } |
| 46 | +}) |
| 47 | + |
| 48 | +const validateSnapshots = (packets, expectedGeometries, count) => { |
| 49 | + const indexes = snapshotIndexes(packets) |
| 50 | + if (indexes.length < count) throw new Error(`expected ${count} snapshots, got ${indexes.length}`) |
| 51 | + for (let snapshot = 0; snapshot < count; snapshot++) { |
| 52 | + const payload = packets[indexes[snapshot]].payload |
| 53 | + const expected = expectedGeometries[snapshot] |
| 54 | + if (!expected || payload.length !== 4 || payload.readUInt16BE(0) !== expected.rows || |
| 55 | + payload.readUInt16BE(2) !== expected.columns) { |
| 56 | + throw new Error(`snapshot ${snapshot + 1} geometry does not match ${expected?.rows}x${expected?.columns}`) |
| 57 | + } |
| 58 | + } |
| 59 | + return indexes |
49 | 60 | } |
50 | | -const exits = packets.filter((packet) => packet.type === 4) |
51 | | -if (exits.length !== 1 || packets.at(-1).type !== 4) throw new Error("stream does not end in one EXIT") |
52 | | -if (!packets.some((packet) => packet.type === 0 && packet.payload.includes(Buffer.from("FINAL_DATA_61e8")))) { |
53 | | - throw new Error("final terminal DATA was not ordered before EXIT") |
| 61 | + |
| 62 | +const validateFinal = (data, expectedGeometries, stdout = Buffer.alloc(0), stderr = Buffer.alloc(0)) => { |
| 63 | + const packets = decode(data, true) |
| 64 | + if (packets.length < 5) throw new Error("too few frames") |
| 65 | + if (packets[0].type !== 10 || packets[1].type !== 5) { |
| 66 | + throw new Error("initial stream does not begin with GEOMETRY, SCREEN") |
| 67 | + } |
| 68 | + const indexes = validateSnapshots(packets, expectedGeometries, expectedGeometries.length) |
| 69 | + if (indexes.length !== expectedGeometries.length) { |
| 70 | + throw new Error(`expected ${expectedGeometries.length} snapshots, got ${indexes.length}`) |
| 71 | + } |
| 72 | + const initial = packets[indexes[0] + 1].payload |
| 73 | + const reconnected = packets[indexes[1] + 1].payload |
| 74 | + const coloredMarker = Buffer.from("\x1b[31mINITIAL_COLOR_61e8") |
| 75 | + if (!initial.includes(coloredMarker) || !reconnected.includes(coloredMarker)) { |
| 76 | + throw new Error("snapshot lost the red SGR state around the initial marker") |
| 77 | + } |
| 78 | + if (!reconnected.includes(Buffer.from("AFTER_DROP_61e8"))) { |
| 79 | + throw new Error("reconnect screen is not the current terminal state") |
| 80 | + } |
| 81 | + const exits = packets.filter((packet) => packet.type === 4) |
| 82 | + if (exits.length !== 1 || packets.at(-1).type !== 4) throw new Error("stream does not end in one EXIT") |
| 83 | + if (!packets.some((packet) => packet.type === 0 && packet.payload.includes(Buffer.from("FINAL_DATA_61e8")))) { |
| 84 | + throw new Error("final terminal DATA was not ordered before EXIT") |
| 85 | + } |
| 86 | + if (packets.some((packet) => ![0, 4, 5, 10].includes(packet.type))) { |
| 87 | + throw new Error("unexpected packet type in machine stream") |
| 88 | + } |
| 89 | + if (stdout.length !== 0) throw new Error("machine attach wrote to stdout") |
| 90 | + for (const marker of ["INITIAL_COLOR_61e8", "AFTER_DROP_61e8", "FINAL_DATA_61e8"]) { |
| 91 | + if (stderr.includes(Buffer.from(marker))) throw new Error(`terminal marker leaked to stderr: ${marker}`) |
| 92 | + } |
54 | 93 | } |
55 | | -if (packets.some((packet) => ![0, 4, 5, 10].includes(packet.type))) { |
56 | | - throw new Error("unexpected packet type in machine stream") |
| 94 | + |
| 95 | +const selfTest = () => { |
| 96 | + const expected = parseGeometries("24x80,13x47") |
| 97 | + const colored = Buffer.from("\x1b[31mINITIAL_COLOR_61e8\x1b[0m") |
| 98 | + const current = Buffer.concat([colored, Buffer.from("\r\nAFTER_DROP_61e8")]) |
| 99 | + const packets = [ |
| 100 | + frame(10, geometry(24, 80)), frame(5, colored), |
| 101 | + frame(10, geometry(13, 47)), frame(5, current), |
| 102 | + frame(0, Buffer.from("FINAL_DATA_61e8")), frame(4, Buffer.alloc(0)), |
| 103 | + ] |
| 104 | + const valid = Buffer.concat(packets) |
| 105 | + validateFinal(valid, expected) |
| 106 | + const uncolored = Buffer.from("\x1b[HINITIAL_COLOR_61e8\x1b[0m") |
| 107 | + const mutations = [ |
| 108 | + () => validateFinal(Buffer.concat([frame(10, geometry(1, 1)), ...packets.slice(1)]), expected), |
| 109 | + () => validateFinal(Buffer.concat([frame(10, geometry(24, 80)), frame(5, uncolored), ...packets.slice(2)]), expected), |
| 110 | + () => validateFinal(Buffer.concat([packets[0], packets[1], packets[2], frame(5, colored), ...packets.slice(4)]), expected), |
| 111 | + () => validateFinal(Buffer.concat([...packets.slice(0, 4), packets[5], packets[4]]), expected), |
| 112 | + () => validateFinal(valid.subarray(0, valid.length - 1), expected), |
| 113 | + () => validateFinal(valid, expected, Buffer.from("unexpected")), |
| 114 | + () => validateFinal(valid, expected, Buffer.alloc(0), Buffer.from("FINAL_DATA_61e8")), |
| 115 | + ] |
| 116 | + for (const mutate of mutations) assert.throws(mutate) |
| 117 | + console.log("ORACLE-MUTATIONS-GREEN-61e8") |
57 | 118 | } |
58 | 119 |
|
59 | | -console.log("PACKAGED-FD-GREEN-61e8") |
60 | | -console.log("INITIAL-SNAPSHOT-GREEN-61e8") |
61 | | -console.log("RECONNECT-SNAPSHOT-GREEN-61e8") |
62 | | -console.log("FRAMED-TERMINAL-STREAM-GREEN-61e8") |
| 120 | +if (process.argv[2] === "--self-test") { |
| 121 | + selfTest() |
| 122 | +} else { |
| 123 | + const [path, mode, expectedRaw, geometryRaw, stdoutPath, stderrPath] = process.argv.slice(2) |
| 124 | + if (!path || !mode || !expectedRaw || !geometryRaw) process.exit(2) |
| 125 | + const data = fs.existsSync(path) ? fs.readFileSync(path) : Buffer.alloc(0) |
| 126 | + const expected = Number(expectedRaw) |
| 127 | + const expectedGeometries = parseGeometries(geometryRaw) |
| 128 | + if (mode === "snapshots") { |
| 129 | + validateSnapshots(decode(data, false), expectedGeometries, expected) |
| 130 | + } else if (mode === "final") { |
| 131 | + if (!stdoutPath || !stderrPath) process.exit(2) |
| 132 | + validateFinal(data, expectedGeometries, fs.readFileSync(stdoutPath), fs.readFileSync(stderrPath)) |
| 133 | + console.log("PACKAGED-FD-GREEN-61e8") |
| 134 | + console.log("INITIAL-SNAPSHOT-GREEN-61e8") |
| 135 | + console.log("RECONNECT-SNAPSHOT-GREEN-61e8") |
| 136 | + console.log("FRAMED-TERMINAL-STREAM-GREEN-61e8") |
| 137 | + } else { |
| 138 | + process.exit(2) |
| 139 | + } |
| 140 | +} |
0 commit comments